Compare commits

..
47 Commits
Author SHA1 Message Date
joshua a45417948d Merge branch 'feat/api-v2-registrations-staff' 2026-10-29 13:16:59 +02:00
joshuaandClaude Opus 5.5 31ee2a9aee Managing registrations on API v2 (1.51.10)
- v2: staff list (paged), event registrations, registration in full (+ /me), status, manual
  registration, bulk, items, invoice, coupon, attendees and form links, form drafts and responses.
- src/services/registrationAdmin.ts holds the rest of registrationController.js (now v1 wrappers).
- Frontend: fetchAllRegistrations(); every registration screen on v2.
- Roadmap: money gets its own release (1.51.11); webhooks and links move to 1.51.16.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:22 +02:00
223 changed files with 43582 additions and 10863 deletions
+404
View File
@@ -7,6 +7,410 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.10] - 2026-10-29
### Added
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
attendees and their form links, attendee form drafts and responses
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
`registrationController.js` (now about 60 lines of v1 wrappers).
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
door, manual registration, self-service, payments, forms, the attendee composers, reports and
the user dashboard all use v2.
### Changed
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
and links are now 1.51.16.
## [1.51.9] - 2026-10-29
### Added
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
bundle, coupon and campus controllers are now thin v1 wrappers.
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
middleware's codes.
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
coupon field and bundle quotes on the register page all use v2.
### Changed
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
coupon field shows that reason as before.
## [1.51.8] - 2026-10-29
### Added
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
are described as multipart in the OpenAPI document.
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
### Changed
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
path; old manifests can still be imported with `/attachments/sync`.
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
(to 1.51.15).
## [1.51.7] - 2026-10-29
### Added
- **API v2 — setting events up:** `GET /events/manage` (every event staff may see, hidden ones too;
`past`, `inactive`, `closed`; campus-scoped), `POST /events`, `PATCH /events/{id}` (only what's sent
changes; `sessionChanges` when sessions were sent), `DELETE /events/{id}` (admins; deactivates),
`POST /events/{id}/copy-from`, and `GET`/`PUT /events/{id}/notify-recipients`. Event records are
typed for their core fields and pass the rest through as stored (`z.looseObject`).
- **`src/services/events.ts`:** create, update, deactivate, the staff list, notify recipients and
copy-from moved out of `eventController.js` (2,217 → 1,717 lines), with the schedule helpers
(`withSchedule`, `scheduleData`, `saveSchedule`). v1 uses it too. The pre-migration fallbacks
(retrying without `registrationDeadline`, `goLiveAt`…) are gone — every install has those columns.
Creating an event now also returns its ticket options.
- Frontend: `fetchManagedEvents()` (`lib/api.ts`) — every event picker, the kiosk, reports and the
set-up screens load events through v2; the event wizard creates, saves, copies and sets
notification recipients through v2. v1 deprecation links for these endpoints.
### Changed
- **pg-boss 11** (servers are on Node 24). It can't upgrade pg-boss 10's tables, so the job queue
uses a new schema, `pgboss_v11`; schedules are recreated at start, and the admin page's
"recent runs" start again from this release. The old `pgboss` schema is left as it was (it can
be dropped: `DROP SCHEMA pgboss CASCADE;`). pg-boss 11 brings cron-parser 5, so the last
cron-parser 4 deprecation is gone.
- Roadmap: the rest of the events area (event details for staff, ticket options and variants,
attachments, sections, costs, series, season passes, bundles, coupons, campuses, calendar file,
alias) is 1.51.8; the later areas move up one (to 1.51.14).
## [1.51.6] - 2026-10-29
### Fixed
- **Command palette (Ctrl+K) was missing things.** It listed 9 of the 19 reports — now every
report (built from the report catalogue, so new ones appear by themselves), plus Dashboard,
Reports, Ticket options and My groups, and your own pages (My events & tickets, My payments,
My privacy, My kids, My serving, My attendee forms, Make a donation). Attendee forms now opens
for staff and supervisors too (the page allows them; the palette had it as admin-only). An e2e
test fails when a dashboard page is neither in the palette nor deliberately left out.
### Changed
- **Excel exports use ExcelTS** (`@cj-tech-master/excelts` 10) instead of exceljs, which is
unmaintained: same API, no dependencies — the backend has 87 fewer packages and none of
exceljs's deprecated ones (fstream, glob 7, inflight, lodash.isequal, rimraf 2). New
`e2e/excel.spec.ts` opens a real report and attendance export. ExcelTS itself now says it's no
longer developed (its successor, documonster, is pre-1.0); existing versions keep working.
- The backend needs **Node 22 or newer** (`engines`); the servers run Node 24 LTS.
- Roadmap: incoming webhooks and links in sent messages get v2 URLs too (1.51.13), with the v1 paths
still answering; the area releases are now 1.51.7 – 1.51.13.
## [1.51.5] - 2026-10-29
### Changed
- **Next.js 16** (from 15.5) and **React 19.3**. Builds and dev use Turbopack (Next 16's default);
`tsconfig.json` `jsx` is `react-jsx` (required by Next 16); `images.domains` → `remotePatterns`.
- **ESLint 10** (ESLint 9 is out of support) with Next 16's own flat configs
(`eslint-config-next/core-web-vitals` and `/typescript`, no more FlatCompat). `npm run lint` is
`eslint src` (`next lint` was removed). Next 16's React Compiler-era rules
(`react-hooks/set-state-in-effect`, `purity`, `immutability`, `static-components`, `refs`) are
warnings for now — 181 places, mostly loading data in `useEffect`.
- Removed the empty `middleware.ts` (a pass-through that did nothing; Next 16 renames middleware to
"proxy"), which also drops its 117 kB bundle.
## [1.51.4] - 2026-10-29
### Security
- **Next.js 15.5.28** (critical: remote code execution through the image optimiser with SVGs, and a
Server Actions denial of service), **@sentry/nextjs 10.78** (critical: middleware bypass),
**zod 4.6.7** (high: memory exhaustion on oversized collections; backend and frontend),
**multer 2.5.0** (high: denial of service from a pending file filter), **nodemailer 10.3.0**
(moderate: three address-parsing flaws that could add recipients), fast-uri 3.1.9 and
ip-address 10.7.5 (moderate). Minimum versions raised in package.json so a fresh install can't
resolve an older one. `npm audit`: frontend and root clean; the backend's 6 remaining reports are
low and dev-only (jest → deepmerge), with no real fix (npm suggests downgrading Jest to 25).
### Changed
- **cron-parser 5** (v4 is unmaintained): `CronExpressionParser.parse` in `src/jobs/index.js`.
- **Node 24 LTS** for new installs (`scripts/install.sh`); Node 20 reached end of life in April 2026.
`deploy.sh` warns on servers older than Node 22; DEPLOYMENT.md has "Upgrading Node".
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 – 1.51.11).
### Known
- Still deprecated, with reasons: exceljs (unmaintained; brings fstream, glob 7, inflight,
lodash.isequal, rimraf 2) — replacing it with ExcelTS waits for approval; ESLint 9 (out of
support) — ESLint 10 needs Next.js 16 (1.51.5); @opentelemetry/sdk-trace-base — pulled in by the
latest @sentry/nextjs, for Sentry to update; glob 10 — inside Jest (dev only).
## [1.51.3] - 2026-10-29
### Changed
- **Express 5** (from 4). Kept Express 4 behaviour where it changed: the `qs` ("extended") query
parser, and `req.body` as `{}` when a request has no JSON body. `@types/express` 5.
- **The ticket scanner and at-the-door check-in use API v2** (`/api/v2/checkin/{qrCode}`). The v2
look-up now gives `attendee`, `bookedBy` and `ticketOptionId` (for section checks).
- **Shared middleware answers in the v2 shape on /api/v2:** rate limits, the impersonation and
licence read-only guards, the auth checks and campus refusals write v1's `{ message, code }`;
on v2 that becomes `{ error: { code, message, reason } }` (`reason` = the specific old code, e.g.
`TWO_FACTOR_REQUIRED`, `LICENCE_READ_ONLY`). Read-only answers have the v2 code `READ_ONLY` (403).
The frontend's `apiFetch` treats `reason` like v1's `code`, so the 2FA and impersonation
handling works on v2 calls too.
### Added
- **v1 deprecation headers:** v1 endpoints that have a v2 replacement answer with
`Deprecation: @<date>` (RFC 9745) and `Link: <…>; rel="successor-version"` (`src/v2/deprecation.ts`).
The rest of v1 has no replacement yet, so no header.
## [1.51.2] - 2026-10-29
### Added
- **API v2 accounts:** `POST /auth/signup` (needs `acceptTerms`), `POST /auth/login` (answers
`signed_in` with a token, or `code_required` for two-factor) and `POST /auth/login/code`, both
behind the login rate limit; `PATCH /me` (only what is sent changes; a new password needs the
current one) and `GET /me` now with `notificationPreference` and `homeCampusId`.
- **API v2 check-in:** `GET /checkin/{qrCode}` (look up: holder, what's left, session, consent)
and `POST /checkin/{qrCode}` (check in everyone left or `people`). Every normal result is a 200
with an `outcome` — `checked_in`, `already_used`, `consent_required`, `wrong_event`,
`wrong_section`, `section_not_found`, `no_session`, `locked`, `transferred`. Staff only, with the
same campus rule as v1.
- **Services:** sign up, log in, the 2FA step and profile changes moved from `userController.js`
(~430 lines shorter) to `src/services/auth.ts`; scan preview and scanning from
`ticketController.js` to `src/services/checkin.ts`. v1 answers exactly as before (`ServiceError`
can carry the old `v1Status`, e.g. 400 for a wrong password, which is 401 in v2).
- v2 routes can take extra middleware (`middleware: [loginLimiter]`).
### Changed
- Logging in (and the 2FA code step), signing up, and changing your details or password use API v2.
The scanner stays on v1 until 1.51.3.
## [1.51.1] - 2026-10-29
### Added
- **API v2 registrations and payments:** `POST /registrations` (signed in, guest for free
events, or a waitlist link), `GET /registrations/{id}`, `POST /registrations/{id}/cancel` and
`POST /registrations/{id}/checkout` (online payment, with an optional donation; answers
`priceUpdated` with the new balance when an early-bird price ran out). Owners only (admins too);
someone else's registration is `NOT_FOUND`.
- **Services in TypeScript:** making and cancelling registrations moved from
`registrationController.js` to `src/services/registrations.ts`, and paying a registration
online from `paymentController.js` to `src/services/payments.ts`. v1 and v2 run the same code;
`ServiceError` carries the HTTP status. `registrationController.js` is ~560 lines shorter.
- **Typed frontend calls:** `npm run api:types` (repo root) writes the OpenAPI document to
`frontend/src/lib/api-v2/openapi.json` and generates `schema.d.ts` from it (openapi-typescript);
`apiV2(method, path, { params, query, body, authToken })` in `frontend/src/lib/apiV2.ts` checks
the path, parameters, body and answer. A backend test fails while the saved document is stale.
### Changed
- The register page, "Pay now" (registration success page and My Events) and cancelling on My
Events use API v2. `apiFetch` reads v2 errors (`error.message`, with `code`/`fields` in `data`).
## [1.51.0] - 2026-10-29
### Added
- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod
schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what
the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin)
shown at **`/docs/v2`** (Swagger UI, served locally). One error shape
`{ error: { code, message, fields? } }` with stable codes, and one list shape
`{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`,
`GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged.
- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` →
`dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed
doors into JS modules in `src/v2/legacy.ts`.
- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`.
### Changed
- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a
launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is
none), so existing PM2 configs keep working unchanged.
- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then
`npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to
1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds.
- e2e starts the backend with tsx.
## [1.50.2] - 2026-10-29
### Added
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
### Changed
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
events too.
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
## [1.50.1] - 2026-10-29
### Added
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
used to stop silently at 200.
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
it used to show only the latest 50.
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
### Fixed
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
day). Both dates are now whole days in the site's time zone.
### Changed
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
opens: it loads the latest payments, today's (for the totals), donations with what they were
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
registrations when they're picked. Faster as the years add up; the page works the same.
## [1.50.0] - 2026-10-28
### Added
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
payment, My Events and the self-service kiosk:
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
and its password eye can now be used with the keyboard.
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
announced; option buttons say which one is chosen; error messages are announced when they
appear.
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
so they don't rely on colour alone. The page honours "reduce motion" on the device.
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
the kiosk switch). New dev dependency `@axe-core/playwright`.
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
### Changed
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
## [1.49.2] - 2026-10-28
### Added
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
when the page opens (but not over a link that already says what to show, such as the
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
an account is anonymised.
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
`20261028180000_saved_filters`.
## [1.49.1] - 2026-10-28
### Added
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
all shown" after filtering, and act on them at once —
- **Send a message** by email or WhatsApp, as an event update to the person who registered
(placeholders as on Email attendees);
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
their tickets by email, as with a single payment;
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
places on the new ticket are checked first;
- **Put in a room or group** (one event) — their named people, with the group's capacity and
gender rules;
- **Export to Excel** (recorded in the access log, like other exports);
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
on the Payments page).
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
## [1.49.0] - 2026-10-28
### Added
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
session today — with people checked in so far against places, children in now at kids church,
and volunteers here against rostered (and how many short). Supervisors and admins also see money
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
with — unpaid registrations for events in the next two weeks, young people waiting for parental
consent, waitlists, cover requests, serving roles in the next week short of people, and for
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
webhook deliveries.
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
`/dashboard/admin/users?q=`.
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
## [1.48.2] - 2026-10-28
### Added
+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 |
+63 -14
View File
@@ -35,10 +35,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
19. [Volunteer rosters](#volunteer-rosters)
20. [Accounts & security](#accounts--security)
21. [Privacy (POPIA)](#privacy-popia)
22. [Help system](#help-system)
23. [Background jobs](#background-jobs)
24. [Operations](#operations)
25. [Page map](#page-map)
22. [Accessibility](#accessibility)
23. [Help system](#help-system)
24. [Background jobs](#background-jobs)
25. [Operations](#operations)
26. [Page map](#page-map)
---
@@ -414,9 +415,24 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
events** lists past ones (latest first), and typing searches every event by name, campus or date.
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
- **Dashboard** — **Today** (each event or session on today: checked in so far
against places, children in now, volunteers here; money in today and this
week) and **Waiting for you** (unpaid registrations for the next two weeks,
parental consent outstanding, waitlists, cover requests, roster gaps; for
admins also cashups due, payments to reconcile, privacy requests, failed
webhooks — each links to where it's dealt with); then KPIs (active events,
revenue, donations, registrations, tickets sold, each vs. the previous month),
revenue trend chart, top events, and recent scans. Staff see Today too.
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
and reports for the role, actions ("New event", "Scan tickets"…), and search:
events; payments by amount ("R450") for supervisors; people and registrations
for admins. Follows campus scoping.
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
@@ -467,12 +483,20 @@ logout controls. Disconnected sessions are recovered automatically.
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
form answers and payments (with who recorded each one). **Bulk actions** on
ticked registrations (or all shown): message (email/WhatsApp), mark paid
(records the balance by method), move to another ticket (same tickets and QR
codes, repriced), put in a room or group, export to Excel, cancel — each
through the same rules as one at a time, with a result per registration.
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
(created, edited, turned off, applied or removed by staff), and every later feature's admin
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
- **Site settings** — see below.
---
@@ -498,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.
@@ -529,7 +553,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
@@ -757,7 +781,7 @@ contact page shows the Information Officer.
and the people affected were told, and drafts of both notifications.
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
deletion requests with a 30-day due date, emailed to the Information Officer and
admins, reminded daily when due within a week. Completing a deletion anonymises the
person: name, contact details, the people they registered, form answers, consent
@@ -768,6 +792,18 @@ log.
---
## Accessibility
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
motion" respected. Branding warns when the primary colour is too light for white text and
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
(`e2e/accessibility.spec.ts`) keep it that way.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
@@ -787,11 +823,15 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
**Resend**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
is added to the operator register; deliveries are deleted after 30 days and with an account.
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
## Background jobs
@@ -849,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**
+1 -1
View File
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
| Setting | Default when unset | Notes |
|---|---|---|
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
+117
View File
@@ -12,6 +12,123 @@ 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.10
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
bulk actions — now run on the new version of the server's connection to the app. They work
just as before.
## 1.51.9
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
now run on the new version of the server's connection to the app. They work just as before.
## 1.51.8
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
costs and picture uploads now run on the new version of the server's connection to the app. They
work just as before.
## 1.51.7
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
from, now run on the new version of the server's connection to the app. They work as before.
- The background job runner was updated. Its list of recent runs (Site settings → Background jobs)
starts afresh with this version.
## 1.51.6
- **Find anything with Ctrl+K.** The search box (Ctrl+K) now lists every report, and pages that
were missing: the dashboard, all reports, ticket options, your groups, and your own events,
payments, privacy, kids, serving, forms and donations.
- Excel downloads use a newer, maintained library. They look the same.
## 1.51.5
- **Updated framework.** The site now runs on the latest version of its web framework. Nothing
changes in how it works.
## 1.51.4 — security
- **Security updates** to the parts the site is built on, including a critical fix in the web
framework. Recommended for everyone.
## 1.51.3
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
of the server's connection to the app, and the server's web framework has been updated. They work
just as before.
## 1.51.2
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
new version of the server's connection to the app. They work just as before.
## 1.51.1
- **More groundwork for 2.0.** Registering, paying online and cancelling now run on the new
version of the server's connection to the app. They work just as before.
## 1.51.0
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use
Cross Events.
## 1.50.2
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
the door, payments, messages, forms, attendance and more) there is now a search box. The list
opens on upcoming events, past ones are one click away, and typing finds any event by name,
campus or date. The events list in Reports can be searched too.
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
another system: what is sent and when, how to check it came from you, and an example message for
every kind of event.
## 1.50.1
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
IP address, filtered by role, and its action list now includes every kind of action. Data
requests can be searched and come a page at a time, and the webhook delivery log can show only
failed deliveries and load older ones.
- **Fix:** the audit log's "To" date now includes that day.
- **Faster Payments page** for churches with a few years of payments behind them.
## 1.50.0
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
every form field, button and error message read out correctly. Faint grey text is darker and
links in sentences are underlined, so they're easier to read.
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
## 1.49.2
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
under a name and get back to them with one click. Star one and it's ready every time you open the
page. They're yours and follow you to any device.
## 1.49.1
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
them in a room or group, export them to Excel, or cancel them.
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
ticket, so any difference shows as owing.
- You see what happened to each one, and anything that couldn't be done says why.
## 1.49.0
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
people are checked in, children in at kids church, and volunteers here — plus money in today and
this week.
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
start "New event", or find an event, a payment by amount ("R450") or a person.
## 1.48.2
- No changes for churches in this version — it adds demo data for showing Cross Events to other
+45 -5
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>
@@ -243,7 +251,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
| GET | `/api/registrations` | staff+ | All registrations |
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
@@ -570,7 +578,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
@@ -643,7 +651,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
| GET | `/api/payments` | supervisor+ | All payments |
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
@@ -933,6 +941,38 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Saved filters
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
else's id answers 404).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
| DELETE | `/api/saved-filters/:id` | Delete |
## Bulk actions on registrations
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
`{ action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }`. Every id is campus-checked.
| action | params | What happens |
|--------|--------|--------------|
| `markPaid` | `method`: cash/eft/card/other, `paidAt?` | A payment for what's still owed (as `POST /api/payments`); refused in licence read-only mode |
| `cancel` | — | As cancelling one registration (coupons released, waitlist offered); no refund |
| `moveOption` | `fromOptionId`, `toOptionId` (same event) | The lines, tickets and attendees move to the new ticket, repriced; status recalculated; places checked; refused in read-only mode |
| `allocate` | `setId`, `groupId` | Each named person placed, as `PUT /api/allocations/sets/:id/assignments` |
| `message` | `channel`: email/whatsapp, `subject` (email), `text` | Per event, as `POST /api/events/:id/email-attendees` / `whatsapp-attendees` with `filter.registrationIds`, kind "update" |
## Dashboard and command palette
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard/today` | staff+ | `{ events: [{ id, title, startsAt, endsAt, sessionLabel, places, checkedIn, kidsIn, volunteers }], money: { today, todayCount, week } \| null, pending: [{ key, label, count, href, tone }] }` — money and pending for supervisors/admins; campus-scoped |
| GET | `/api/dashboard/search?q=` | staff+ | `{ results: [{ type: event\|payment\|person\|registration, id, title, subtitle, href }] }` — payments by amount for supervisors, people and registrations for admins |
## Event series
| Method | Path | Auth | Description |
@@ -967,13 +1007,13 @@ 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 |
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
## Season passes
+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/'],
};
+1399 -1345
View File
File diff suppressed because it is too large Load Diff
+32 -12
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.48.2",
"version": "1.51.10",
"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",
"pg-boss": "^11.1.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
"swagger-ui-dist": "^5.34.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"uuid": "^11.1.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.43",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-dist": "^3.30.6",
"jest": "^30.4.2",
"prisma": "^7.10.1"
"prisma": "^7.10.1",
"supertest": "^7.3.1",
"ts-jest": "^29.4.15"
},
"overrides": {
"uuid": "^11.1.1",
@@ -54,6 +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"
}
}
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "SavedFilter" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"page" TEXT NOT NULL,
"name" TEXT NOT NULL,
"params" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
-- CreateIndex
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
-- AddForeignKey
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+19
View File
@@ -164,6 +164,7 @@ model User {
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -818,6 +819,7 @@ enum AdminAuditAction {
series_deleted
season_pass_changed
webhook_endpoint_changed
registrations_bulk
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -2296,3 +2298,20 @@ model WebhookDelivery {
@@index([userId])
@@index([createdAt])
}
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
/// events, reports), `params` the filter values. At most one default per person and page.
model SavedFilter {
id String @id @default(uuid())
userId String
page String
name String
params Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, page, name])
@@index([userId, page])
}
+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
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
const { page, limit, actorId, action, role, from, to, search } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
@@ -1,4 +1,5 @@
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
+17 -319
View File
@@ -1,325 +1,23 @@
const prisma = require('../config/db');
// Bundle deals (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
const deals = require('../services/deals');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const { scopeFor } = require('../utils/campusScope');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
// @route GET /api/bundles/event/:eventId — with how often each has been used
const listBundles = async (req, res) => { try { res.json(await deals.listBundles(req.params.eventId)); } catch (e) { fail(res, e); } };
// @route POST /api/bundles/event/:eventId
const createBundle = async (req, res) => { try { res.status(201).json(await deals.createBundle(req.params.eventId, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/bundles/:id
const updateBundle = async (req, res) => { try { res.json(await deals.updateBundle(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/bundles/:id (a used one is switched off instead)
const deleteBundle = async (req, res) => { try { res.json(await deals.deleteBundle(req.params.id, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/bundles/quote — the deals a basket gets, before any coupon
const quoteBundles = async (req, res) => { try { res.json(await deals.quoteBundles(req.body || {}, actorOf(req))); } catch (e) { fail(res, e); } };
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
};
function shape(bundle, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return {
...bundle,
items,
summary: describeBundleItems(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw new Error('Give the bundle a name');
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
async function eventOr404(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) { res.status(404); throw new Error('Event not found'); }
return event;
}
// @desc An event's bundle deals with how often each has been used
// @route GET /api/bundles/event/:eventId
// @access Private/Supervisor
const listBundles = async (req, res) => {
try {
await eventOr404(req.params.eventId, res);
const bundles = await prisma.eventBundle.findMany({
where: { eventId: req.params.eventId },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
res.json(bundles.map(b => ({
...shape(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a bundle deal
// @route POST /api/bundles/event/:eventId
// @access Private/Supervisor
const createBundle = async (req, res) => {
try {
const event = await eventOr404(req.params.eventId, res);
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.create({
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Edit a bundle deal (including switching it off). Registrations that already
// have it are only re-worked when their own items change.
// @route PUT /api/bundles/:id
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
res.json(shape(bundle));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
// registrations that got it keep their price.
// @route DELETE /api/bundles/:id
// @access Private/Supervisor
const deleteBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) {
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
} else {
await prisma.eventBundle.delete({ where: { id: existing.id } });
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
ip: getClientIp(req),
});
res.json(used > 0
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true, message: 'Bundle deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc The bundle deals a basket gets, before any coupon code
// @route POST /api/bundles/quote
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
const quoteBundles = async (req, res) => {
try {
const { eventId, options, forUserId } = req.body || {};
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
const bundles = await previewBundles(eventId, lines);
res.json({
subtotal,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
total: round2(Math.max(0, subtotal - bundles.discount)),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** The deals to show on an event's page: available bundles, without internal fields. */
async function publicBundlesForEvent(eventId) {
const bundles = await prisma.eventBundle.findMany({
where: { eventId, isActive: true },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const now = new Date();
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent: deals.publicBundlesForEvent };
+23 -188
View File
@@ -1,193 +1,28 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
// Campuses (v1). The logic is in services/campuses.ts, shared with /api/v2 (1.51.9).
const campuses = require('../services/campuses');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
const { scopeFor } = require('../utils/campusScope');
const audit = (req, action, campus, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
});
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
// A slug that's already taken → a message instead of a Prisma error.
function friendlyError(e) {
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
return e.message || safeErrorMessage(e);
}
const ADMIN_INCLUDE = {
notifyRecipients: { select: { id: true, name: true, email: true } },
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
};
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
// @route GET /api/campuses
// @access Public
const getCampuses = async (req, res) => {
try {
const enabled = await campusesEnabled();
const campuses = enabled
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
: [];
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc A campus landing page: the campus (active only)
// @route GET /api/campuses/by-slug/:slug
// @access Public
const getCampusBySlug = async (req, res) => {
try {
const campus = (await campusesEnabled())
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
: null;
if (!campus) return res.status(404).json({ message: 'Campus not found' });
res.json(campus);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
// @route GET /api/campuses/admin
// @access Private/Admin
const getCampusesAdmin = async (req, res) => {
try {
const [enabled, campuses] = await Promise.all([
campusesEnabled(),
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
]);
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
// @route PUT /api/campuses/settings body: { enabled }
// @access Private/Admin
const updateCampusSettings = async (req, res) => {
try {
const enabled = req.body?.enabled === true;
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
invalidateSettingsCache();
await warmCache();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
res.json({ enabled });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Add a campus (added at the end of the list)
// @route POST /api/campuses
// @access Private/Admin
const createCampus = async (req, res) => {
try {
const { data, error } = cleanCampusInput(req.body);
if (error) return res.status(400).json({ message: error });
const last = await prisma.campus.aggregate({ _max: { order: true } });
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
audit(req, 'campus_created', campus, { slug: campus.slug });
res.status(201).json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Change a campus: details, summary recipients, archived or not
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
// @access Private/Admin
const updateCampus = async (req, res) => {
try {
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ message: 'Campus not found' });
const { data, error } = cleanCampusInput(req.body, { partial: true });
if (error) return res.status(400).json({ message: error });
if (Array.isArray(req.body?.notifyRecipientIds)) {
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
// Summaries go to people who run events — never to ordinary users.
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
}
let archiveChange = null;
if (req.body?.archived !== undefined) {
const archived = req.body.archived === true;
if (archived !== !!existing.archivedAt) {
data.archivedAt = archived ? new Date() : null;
archiveChange = archived;
}
}
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
if (changed.length) audit(req, 'campus_updated', campus, { changed });
res.json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Put the campuses in this order (filters and lists follow it)
// @route PUT /api/campuses/order body: { ids: [...] }
// @access Private/Admin
const reorderCampuses = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
res.json({ ok: true });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
// @route DELETE /api/campuses/:id
// @access Private/Admin
const deleteCampus = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
// Deleting would drop their limit and leave them unscoped (seeing every campus).
if (campus._count.scopedStaff > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
}
if (campus._count.events > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
}
await prisma.campus.delete({ where: { id: campus.id } });
audit(req, 'campus_deleted', campus, { slug: campus.slug });
res.json({ message: 'Campus deleted' });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
// ?registrants=true) people registered for X's events in the last 12 months. The
// composer sends to these ids as usual, so news consent is still checked on sending.
// @route GET /api/campuses/:id/audience?registrants=true
// @access Private/Supervisor
const getCampusAudience = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
const scope = await require('../utils/campusScope').scopeFor(req);
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
const users = await prisma.user.findMany({
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
select: { id: true, name: true, email: true, phoneNumber: true },
orderBy: { name: 'asc' },
});
res.json({ campus, users });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @route GET /api/campuses — whether they're on, and the active ones
const getCampuses = async (req, res) => { try { res.json(await campuses.publicCampuses()); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/by-slug/:slug
const getCampusBySlug = async (req, res) => { try { res.json(await campuses.campusBySlug(req.params.slug)); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/admin
const getCampusesAdmin = async (req, res) => { try { res.json(await campuses.adminCampuses()); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/settings { enabled }
const updateCampusSettings = async (req, res) => { try { res.json(await campuses.setCampusesEnabled(req.body?.enabled === true, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/campuses
const createCampus = async (req, res) => { try { res.status(201).json(await campuses.createCampus(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/:id
const updateCampus = async (req, res) => { try { res.json(await campuses.updateCampus(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/campuses/order { ids }
const reorderCampuses = async (req, res) => { try { await campuses.reorderCampuses(req.body?.ids); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route DELETE /api/campuses/:id
const deleteCampus = async (req, res) => { try { await campuses.deleteCampus(req.params.id, req.user, await ctxOf(req)); res.json({ message: 'Campus deleted' }); } catch (e) { fail(res, e); } };
// @route GET /api/campuses/:id/audience?registrants=true
const getCampusAudience = async (req, res) => { try { res.json(await campuses.campusAudience(req.params.id, req.query.registrants === 'true', await ctxOf(req))); } catch (e) { fail(res, e); } };
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
+15 -148
View File
@@ -1,177 +1,44 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Planned event costs (cashup and the profit report). Shared with /api/v2 (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
function normalizePaidFromMethod(value) {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (!ALL_METHODS.includes(value)) {
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
}
return value;
}
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
// @desc List costs for an event
// @route GET /api/events/:eventId/costs
// @access Private/Supervisor
const getEventCosts = async (req, res) => {
try {
const costs = await prisma.eventCost.findMany({
where: { eventId: req.params.eventId },
include: { eventOption: { select: { id: true, name: true } } },
orderBy: { createdAt: 'asc' }
});
res.json(costs);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.listCosts(req.params.eventId));
} catch (error) { fail(res, error); }
};
// @desc Create a cost for an event
// @route POST /api/events/:eventId/costs
// @access Private/Supervisor
// @access Private/Admin
const createEventCost = async (req, res) => {
try {
const { eventId } = req.params;
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
await assertEventOpen(eventId, res);
if (!label || !String(label).trim()) {
res.status(400);
throw new Error('Label is required');
}
if (costType !== 'once_off' && costType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
if (costType === 'per_item' && !eventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
if (costType === 'per_item') {
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
if (!option || option.eventId !== eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
const cost = await prisma.eventCost.create({
data: {
id: uuidv4(),
eventId,
label: String(label).trim(),
costType,
amount: amt,
eventOptionId: costType === 'per_item' ? eventOptionId : null,
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
notes: notes || null
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.status(201).json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.status(201).json(await setup.createCost(req.params.eventId, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Update a cost
// @route PUT /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const updateEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
const nextCostType = costType !== undefined ? costType : existing.costType;
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const nextEventOptionId = nextCostType === 'per_item'
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
: null;
if (nextCostType === 'per_item') {
if (!nextEventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
if (!option || option.eventId !== existing.eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
let nextAmount = existing.amount;
if (amount !== undefined) {
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
nextAmount = amt;
}
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
const cost = await prisma.eventCost.update({
where: { id: req.params.id },
data: {
label: label !== undefined ? String(label).trim() : existing.label,
costType: nextCostType,
amount: nextAmount,
eventOptionId: nextEventOptionId,
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
notes: notes !== undefined ? notes : existing.notes
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.updateCost(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Delete a cost
// @route DELETE /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const deleteEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
await prisma.eventCost.delete({ where: { id: req.params.id } });
await setup.deleteCost(req.params.id);
res.json({ message: 'Cost deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
} catch (error) { fail(res, error); }
};
module.exports = {
getEventCosts,
createEventCost,
updateEventCost,
deleteEventCost
};
module.exports = { getEventCosts, createEventCost, updateEventCost, deleteEventCost };
+24 -379
View File
@@ -1,392 +1,37 @@
const prisma = require('../config/db');
// Coupon codes (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
const deals = require('../services/deals');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { listWhere } = require('../utils/campusScope');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
const { scopeFor } = require('../utils/campusScope');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
const dateOrNull = (v) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
async function eventIdsAllowed(scope, eventIds) {
if (!scope) return true;
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
}
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const scope = await scopeFor(req);
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
.filter(c => couponAllowed(scope, couponCampuses(c)));
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
const scope = await scopeFor(req);
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/**
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
async function basketBuyer(req, forUserId) {
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
return { buyer, isStaff };
}
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
// sessions ticked is still one line at one price).
const { isPickOption } = require('../utils/sessions');
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
return buildCouponLines({ registrationOptions });
}
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
// @route GET /api/coupons — with use counts
const listCoupons = async (req, res) => { try { res.json(await deals.listCoupons(await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route GET /api/coupons/:id — with its redemptions
const getCoupon = async (req, res) => { try { res.json(await deals.getCoupon(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/coupons
const createCoupon = async (req, res) => { try { res.status(201).json(await deals.createCoupon(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/coupons/:id
const updateCoupon = async (req, res) => { try { res.json(await deals.updateCoupon(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/coupons/validate — a code against a basket ({ valid: false } answers 400, as before)
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, eventId and options are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer, isStaff } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount, bundles } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
subtotal,
total: Math.max(0, subtotal - bundles.discount - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
const result = await deals.checkCode(req.body || {}, actorOf(req));
res.status(result.valid ? 200 : 400).json(result);
} catch (e) { fail(res, e); }
};
// @desc Coupon and bundle-deal uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to= — the Discounts report
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
res.json([
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id,
kind: 'bundle',
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
]);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await deals.listRedemptions({ eventIds, from: dateOrNull(req.query.from), to: dateOrNull(req.query.to) }, await listWhere(req, 'event')));
} catch (e) { fail(res, e); }
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
File diff suppressed because it is too large Load Diff
@@ -154,6 +154,9 @@ const test = async (req, res) => {
const deliveries = async (req, res) => {
try {
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
// "Load older": the next page is everything before the last one shown.
const before = req.query.before ? new Date(String(req.query.before)) : null;
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
const rows = await prisma.webhookDelivery.findMany({
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
+16 -49
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');
@@ -354,7 +353,15 @@ const getPayments = async (req, res) => {
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
// ?donations=1 (donations and the legs that apply them to registrations).
const filters = [];
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const from = req.query.from ? new Date(String(req.query.from)) : null;
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
@@ -933,54 +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);
}
+11 -5
View File
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
const q = String(req.query.search || '').trim().slice(0, 100);
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
// Closed requests pile up over the years, so they come a page at a time (newest first).
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
take: limit,
skip: (page - 1) * limit,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
}), prisma.dataRequest.count({ where })]);
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
} catch (error) {
fail(res, error);
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const fs = require('fs');
const path = require('path');
const { sendMail, emailWrapper } = require('../utils/email');
+19 -141
View File
@@ -1,146 +1,24 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
// Season passes (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
const series = require('../services/series');
const { getClientIp } = require('../utils/requestUtils');
const { scopeFor, canManage } = require('../utils/campusScope');
const passes = require('../utils/seasonPass');
const { scopeFor } = require('../utils/campusScope');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const audit = (req, pass, action, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
});
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
async function assertCampuses(req, campusIds) {
const scope = await scopeFor(req);
if (scope && campusIds.some(c => !canManage(scope, c))) {
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
err.statusCode = 403;
throw err;
}
}
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// @desc A series' passes with their events, shares, and how many were sold
// @route GET /api/season-passes?seriesId=
// @access Private/Supervisor
const listPasses = async (req, res) => {
try {
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
res.json(rows.map(p => ({
...passes.passView(p),
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
})));
} catch (error) { fail(res, error); }
};
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
// @access Private/Supervisor
const createPass = async (req, res) => {
try {
const seriesId = String(req.body?.seriesId || '');
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
await assertCampuses(req, items.map(i => i.campusId));
const pass = await prisma.seasonPass.create({
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
});
audit(req, pass, 'created', { events: items.length, price: pass.price });
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
} catch (error) { fail(res, error); }
};
// @desc Change a pass. Its events can only change while nobody has bought it.
// @route PUT /api/season-passes/:id
// @access Private/Supervisor
const updatePass = async (req, res) => {
try {
const current = await passes.loadPass(req.params.id);
await assertCampuses(req, current.items.map(i => i.event.campusId));
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
}
if (items) await assertCampuses(req, items.map(i => i.campusId));
await prisma.$transaction(async tx => {
await tx.seasonPass.update({ where: { id: current.id }, data });
if (items) {
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
}
});
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
res.json(passes.passView(await passes.loadPass(current.id)));
} catch (error) { fail(res, error); }
};
// @desc Delete a pass nobody has bought (otherwise switch it off)
// @route DELETE /api/season-passes/:id
// @access Private/Supervisor
const deletePass = async (req, res) => {
try {
const current = await passes.loadPass(req.params.id);
await assertCampuses(req, current.items.map(i => i.event.campusId));
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
}
await prisma.seasonPass.delete({ where: { id: current.id } });
audit(req, current, 'deleted');
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
// @route POST /api/season-passes/:id/buy { quantity, provider? }
// @access Private
const buy = async (req, res) => {
try {
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
const back = `${frontendUrl()}/series/${pass.series.slug}`;
const result = await passes.buyPass(req.params.id, req.user.id, {
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
});
res.status(201).json(result);
} catch (error) { fail(res, error); }
};
// @desc Pay what's still owed on a purchase (a new checkout)
// @route POST /api/season-passes/purchases/:id/checkout
// @access Private (the buyer)
const payPurchase = async (req, res) => {
try {
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
}));
} catch (error) { fail(res, error); }
};
// @desc The signed-in person's pass purchases
// @route GET /api/season-passes/mine
// @access Private
const myPurchases = async (req, res) => {
try {
const rows = await prisma.seasonPassPurchase.findMany({
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
select: {
id: true, quantity: true, total: true, status: true, createdAt: true,
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
},
});
res.json(rows);
} catch (error) { fail(res, error); }
};
// @route GET /api/season-passes?seriesId=
const listPasses = async (req, res) => { try { res.json(await series.listPasses(req.query.seriesId ? String(req.query.seriesId) : undefined)); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes
const createPass = async (req, res) => { try { res.status(201).json(await series.createPass(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/season-passes/:id (events and price fixed once someone has bought it)
const updatePass = async (req, res) => { try { res.json(await series.updatePass(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/season-passes/:id
const deletePass = async (req, res) => { try { await series.deletePass(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes/:id/buy { quantity, provider? }
const buy = async (req, res) => { try { res.status(201).json(await series.buyPass(req.params.id, req.user.id, { quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
// @route POST /api/season-passes/purchases/:id/checkout
const payPurchase = async (req, res) => { try { res.json(await series.payPassPurchase(req.params.id, req.user.id, { provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
// @route GET /api/season-passes/mine
const myPurchases = async (req, res) => { try { res.json(await series.myPassPurchases(req.user.id)); } catch (e) { fail(res, e); } };
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
+20 -164
View File
@@ -1,189 +1,45 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Scanning sections: which ticket types may come in at which door. Shared with
// /api/v2/sections (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { listWhere } = require('../utils/campusScope');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
//
// @desc Get sections (optionally by event)
// @route GET /api/sections?eventId=xxx
// @access Private/Staff
//
const getSections = async (req, res) => {
try {
const { eventId } = req.query;
const { withScope, listWhere } = require('../utils/campusScope');
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
const sections = await prisma.section.findMany({
where,
include: {
allowedOptions: {
include: {
eventOption: true
}
},
event: {
select: { id: true, title: true }
}
},
orderBy: { name: 'asc' }
});
res.json(sections);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.listSections(req.query.eventId, await listWhere(req, 'event')));
} catch (error) { fail(res, error); }
};
//
// @desc Create section
// @route POST /api/sections
// @access Private/Admin/Supervisor
//
const createSection = async (req, res) => {
try {
const { eventId, name, allowedOptionIds } = req.body;
if (!eventId || !name) {
res.status(400);
throw new Error('Event and section name are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
const section = await prisma.section.create({
data: {
id: uuidv4(),
eventId,
name,
updatedAt: new Date()
}
});
// Attach allowed options if provided
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
})),
skipDuplicates: true
});
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.status(201).json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.status(201).json(await setup.createSection(req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Update section
// @route PUT /api/sections/:id
// @access Private/Admin/Supervisor
//
const updateSection = async (req, res) => {
try {
const { name, allowedOptionIds } = req.body;
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
const updatedSection = await prisma.section.update({
where: { id: req.params.id },
data: {
name: name || section.name,
updatedAt: new Date()
}
});
// If allowed options supplied → reset them
if (Array.isArray(allowedOptionIds)) {
await prisma.sectionOption.deleteMany({
where: { sectionId: section.id }
});
if (allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
}))
});
}
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.updateSection(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Delete section
// @route DELETE /api/sections/:id
// @access Private/Admin
//
const deleteSection = async (req, res) => {
try {
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
await prisma.section.delete({
where: { id: section.id }
});
res.json({ message: 'Section deleted' });
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
await setup.deleteSection(req.params.id);
res.json({ message: 'Section deleted' });
} catch (error) { fail(res, error); }
};
module.exports = {
getSections,
createSection,
updateSection,
deleteSection
};
module.exports = { getSections, createSection, updateSection, deleteSection };
+20 -121
View File
@@ -1,126 +1,25 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
// Event series (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
const series = require('../services/series');
const { getClientIp } = require('../utils/requestUtils');
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
const { scopeFor, canView, eventWhere } = require('../utils/campusScope');
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor } = require('../utils/campusScope');
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
});
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
const audit = (req, action, series, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
});
// @desc Every series, with its events (the ones this person may see) and number of followers
// @route GET /api/series
// @access Private/Supervisor
const listSeries = async (req, res) => {
try {
const scope = await scopeFor(req);
const rows = await prisma.eventSeries.findMany({
orderBy: { name: 'asc' },
include: {
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
_count: { select: { followers: true, events: true } },
},
});
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
res.json(rows.map(({ _count, events, ...s }) => ({
...s,
followers: _count.followers,
eventCount: _count.events,
events: events.filter(e => canView(scope, e.campusId)),
})));
} catch (error) { fail(res, error); }
};
// @desc Create a series
// @route POST /api/series
// @access Private/Supervisor
const createSeries = async (req, res) => {
try {
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
audit(req, 'series_created', series);
res.status(201).json(series);
} catch (error) { fail(res, error); }
};
// @desc Rename a series or change its page
// @route PUT /api/series/:id
// @access Private/Supervisor
const updateSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Series not found' });
const data = cleanSeriesInput(req.body, current);
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
res.json(series);
} catch (error) { fail(res, error); }
};
// @desc Delete a series (its events stay, just without a series; followers are dropped)
// @route DELETE /api/series/:id
// @access Private/Admin
const deleteSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
if (!current) return res.status(404).json({ message: 'Series not found' });
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
return res.status(409).json({ message: 'People have bought season passes for this series, so it can\'t be deleted.' });
}
await prisma.eventSeries.delete({ where: { id: current.id } });
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc A series' public page: its details, upcoming and past public events, and whether the
// signed-in person follows it
// @route GET /api/series/by-slug/:slug
// @access Public
const getSeriesBySlug = async (req, res) => {
try {
const series = await prisma.eventSeries.findUnique({
where: { slug: String(req.params.slug) },
select: { id: true, name: true, slug: true, description: true, picture: true },
});
if (!series) return res.status(404).json({ message: 'Series not found' });
const events = await prisma.event.findMany({
where: { ...publicEventWhere(), seriesId: series.id },
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
});
const { upcoming, past } = splitEvents(events);
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
// Season passes switched on (1.47.3); `unavailable` says why one can't be bought now.
const { passView, PASS_INCLUDE } = require('../utils/seasonPass');
const passes = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: PASS_INCLUDE }))
.map(p => passView(p))
.map(({ items, ...p }) => ({ ...p, items: items.map(({ campusId, ...i }) => i) }));
res.json({ ...series, upcoming, past, following, passes });
} catch (error) { fail(res, error); }
};
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
// (the events this person may see), oldest first, with the change from the one before
// @route GET /api/series/:id/compare
// @access Private/Supervisor
const compare = async (req, res) => {
try {
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
} catch (error) { fail(res, error); }
};
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
// @access Private
const followSeries = async (req, res) => {
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
};
const unfollowSeries = async (req, res) => {
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
};
// @route GET /api/series — every series, its events (the ones this person may see) and followers
const listSeries = async (req, res) => { try { res.json(await series.listSeries(await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST /api/series
const createSeries = async (req, res) => { try { res.status(201).json(await series.createSeries(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route PUT /api/series/:id
const updateSeries = async (req, res) => { try { res.json(await series.updateSeries(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route DELETE /api/series/:id (events stay, without a series; followers are dropped)
const deleteSeries = async (req, res) => { try { await series.deleteSeries(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
// @route GET /api/series/by-slug/:slug — the public page
const getSeriesBySlug = async (req, res) => { try { res.json(await series.seriesPage(req.params.slug, req.user?.id || null)); } catch (e) { fail(res, e); } };
// @route GET /api/series/:id/compare — year on year
const compare = async (req, res) => { try { res.json(await series.compareSeries(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
// @route POST|DELETE /api/series/:id/follow
const followSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, true)); } catch (e) { fail(res, e); } };
const unfollowSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, false)); } catch (e) { fail(res, e); } };
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
+18 -273
View File
@@ -1,8 +1,9 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
// Scanning, shared with /api/v2/checkin (TypeScript, 1.51.2).
const checkin = require('../services/checkin');
// @desc Generate tickets for a registration
// @route POST /api/tickets/generate
@@ -256,99 +257,11 @@ const getTicketByQrCode = async (req, res) => {
// @access Private/Staff
const getScanPreview = async (req, res) => {
try {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw`
SELECT
t.id,
t."qrCode",
t.quantity,
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages
});
const result = await checkin.previewScan(req.params.qrCode, { sessionId: req.query.sessionId || null });
if (result.outcome === 'transferred') return res.status(410).json({ message: result.message, code: 'TICKET_TRANSFERRED' });
res.json(result.ticket);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
@@ -357,187 +270,19 @@ const getScanPreview = async (req, res) => {
// @access Private/Staff
const scanTicket = async (req, res) => {
try {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw`
SELECT
t.id,
t.quantity,
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const ticket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
// Optional server-side guard: ensure ticket belongs to the requested event when provided
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
remaining: 0
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
const fullyUsed = newRemaining <= 0;
// Run the usage insert and ticket status update in parallel — they don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: {
id: uuidv4(),
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
});
const { getClientIp } = require('../utils/requestUtils');
const result = await checkin.scanTicket(req.params.qrCode, req.user, {
eventId: req.query?.eventId || req.body?.eventId || null,
sectionId: req.query?.sectionId || req.body?.sectionId || null,
sessionId: req.body?.sessionId || req.query?.sessionId || null,
deviceId: req.body?.deviceId || null,
qty: parseInt(req.body?.qty || req.body?.quantity) || null,
overrideConsent: req.body?.overrideConsent === true,
}, getClientIp(req));
const { status, body } = checkin.v1ScanResponse(result);
res.status(status).json(body);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
+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 -1783
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -15,10 +15,13 @@
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const { CronExpressionParser } = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
// pg-boss 11 can't upgrade pg-boss 10's schema (it migrates only from 11.0), so it has its own.
// The old `pgboss` schema (10.x) is left as it was; it can be dropped (DROP SCHEMA pgboss CASCADE)
// once 1.51.7 has run — it only held queued runs and their history.
const SCHEMA = 'pgboss_v11';
const JOBS = [
{
@@ -105,7 +108,7 @@ function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
CronExpressionParser.parse(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
@@ -116,7 +119,7 @@ function cronFor(job) {
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
return CronExpressionParser.parse(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
@@ -270,11 +273,8 @@ async function jobStatus(prisma) {
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
-- pg-boss 11 keeps finished runs in job until they expire (there's no archive table).
FROM (SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const dashboard = require('../utils/dashboard');
// Both follow the person's campus scope inside utils/dashboard.js.
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
// @route GET /api/dashboard/today
// @access Private/Staff (money and waiting items for supervisors and admins)
router.get('/today', protect, staff, scopeList, async (req, res) => {
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
});
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
// @route GET /api/dashboard/search
// @access Private/Staff
router.get('/search', protect, staff, scopeList, async (req, res) => {
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
});
module.exports = router;
+5 -1
View File
@@ -17,7 +17,7 @@ const {
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
const { notOnSeasonPass } = require('../utils/seasonPass');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
@@ -46,5 +46,9 @@ router.get('/', protect, staff, scopeList, getRegistrations);
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const filters = require('../utils/savedFilters');
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
router.get('/', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
});
router.post('/', protect, staff, OWN, async (req, res) => {
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.put('/:id', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.delete('/:id', protect, staff, OWN, async (req, res) => {
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
});
module.exports = router;
+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 };
+156
View File
@@ -0,0 +1,156 @@
/**
* Campuses (1.51.9): the public list and landing pages, Site settings → Campuses, and "everyone at
* campus X" for messages. Moved from campusController.js so v1 and /api/v2 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 campusesUtil = require('../utils/campuses') as {
SETTING_KEY: string;
CAMPUS_PUBLIC_SELECT: Record<string, true>;
campusesEnabled(): Promise<boolean>;
cleanCampusInput(body: any, opts?: { partial?: boolean }): { data: Record<string, any>; error?: string };
campusAudienceWhere(campusId: string, opts: { includeRegistrants: boolean }): object;
};
const settingsCache = require('../utils/settingsCache') as { invalidate(): void; warmCache(): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role };
export type Ctx = { scope: { campusIds: string[] } | null; ip?: string };
const ADMIN_INCLUDE = {
notifyRecipients: { select: { id: true, name: true, email: true } },
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
} as const;
const audit = (actor: Actor, ctx: Ctx, action: string, campus: { id?: string; name?: string } | null, metadata: object) => logAdminAction({
actorId: actor.id, actorRole: actor.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: ctx.ip,
});
/** A taken web address → a message instead of a database error. */
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e?.code === 'P2002') throw new ServiceError(409, 'Another campus already uses that web address — choose a different one.');
if (e instanceof ServiceError) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'The campus could not be saved');
}
}
/** Whether campuses are on, and the active ones in order (public filters, event cards). */
export async function publicCampuses() {
const enabled = await campusesUtil.campusesEnabled();
const campuses = enabled
? await prisma.campus.findMany({ where: { archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
: [];
return { enabled, campuses };
}
/** A campus landing page (active campuses, while campuses are on). */
export async function campusBySlug(slug: string) {
const campus = (await campusesUtil.campusesEnabled())
? await prisma.campus.findFirst({ where: { slug: String(slug), archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT })
: null;
if (!campus) return fail(404, 'Campus not found');
return campus;
}
/** Every campus, archived ones last, with event counts and summary recipients. */
export async function adminCampuses() {
const [enabled, campuses] = await Promise.all([
campusesUtil.campusesEnabled(),
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
]);
return { enabled, campuses };
}
/** Campuses on or off (off hides campus fields and filters; nothing is deleted). */
export async function setCampusesEnabled(enabled: boolean, actor: Actor, ctx: Ctx) {
await prisma.appSetting.upsert({ where: { key: campusesUtil.SETTING_KEY }, update: { value: String(enabled) }, create: { key: campusesUtil.SETTING_KEY, value: String(enabled) } });
settingsCache.invalidate();
await settingsCache.warmCache();
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: ctx.ip });
return { enabled };
}
/** A new campus, at the end of the list. */
export function createCampus(body: any, actor: Actor, ctx: Ctx) {
return slugSafe(async () => {
const { data, error } = campusesUtil.cleanCampusInput(body);
if (error) fail(400, error);
const last = await prisma.campus.aggregate({ _max: { order: true } });
const campus = await prisma.campus.create({ data: { ...(data as any), order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
audit(actor, ctx, 'campus_created', campus, { slug: campus.slug });
return campus;
});
}
/** Changes a campus: details, who gets its summaries (staff only), archived or not. */
export function updateCampus(campusId: string, body: any, actor: Actor, ctx: Ctx) {
return slugSafe(async () => {
const existing = await prisma.campus.findUnique({ where: { id: campusId } });
if (!existing) return fail(404, 'Campus not found');
const { data, error } = campusesUtil.cleanCampusInput(body, { partial: true });
if (error) fail(400, error);
if (Array.isArray(body?.notifyRecipientIds)) {
const ids = body.notifyRecipientIds.filter((x: unknown) => typeof x === 'string' && x);
// Summaries go to people who run events — never to ordinary users.
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
}
let archiveChange: boolean | null = null;
if (body?.archived !== undefined) {
const archived = body.archived === true;
if (archived !== !!existing.archivedAt) {
data.archivedAt = archived ? new Date() : null;
archiveChange = archived;
}
}
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
if (archiveChange !== null) audit(actor, ctx, 'campus_archived', campus, { archived: archiveChange });
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
if (changed.length) audit(actor, ctx, 'campus_updated', campus, { changed });
return campus;
});
}
/** Puts the campuses in this order (filters and lists follow it). */
export async function reorderCampuses(ids: string[]) {
const clean = (Array.isArray(ids) ? ids : []).filter(x => typeof x === 'string');
if (!clean.length) fail(400, 'Send the campus ids in their new order.');
await prisma.$transaction(clean.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
}
/** Deletes a campus with no events and no staff limited to it (otherwise archive it). */
export async function deleteCampus(campusId: string, actor: Actor, ctx: Ctx) {
const campus = await prisma.campus.findUnique({ where: { id: campusId }, include: { _count: { select: { events: true, scopedStaff: true } } } });
if (!campus) return fail(404, 'Campus not found');
// Deleting would drop their limit and leave them unscoped (seeing every campus).
if (campus._count.scopedStaff > 0) {
fail(409, `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.`, 'CAMPUS_HAS_SCOPED_STAFF');
}
if (campus._count.events > 0) {
fail(409, `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.`, 'CAMPUS_HAS_EVENTS');
}
await prisma.campus.delete({ where: { id: campus.id } });
audit(actor, ctx, 'campus_deleted', campus, { slug: campus.slug });
}
/**
* "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
* `includeRegistrants`) people registered for X's events in the last 12 months. News consent is
* still checked when sending. Campus-scoped staff only get their own campuses.
*/
export async function campusAudience(campusId: string, includeRegistrants: boolean, ctx: Ctx) {
const campus = await prisma.campus.findUnique({ where: { id: campusId }, select: { id: true, name: true } });
if (!campus || (ctx.scope && !ctx.scope.campusIds.includes(campus.id))) return fail(404, 'Campus not found');
const users = await prisma.user.findMany({
where: campusesUtil.campusAudienceWhere(campus.id, { includeRegistrants }),
select: { id: true, name: true, email: true, phoneNumber: true },
orderBy: { name: 'asc' },
});
return { campus, users };
}
+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 };
}
}
+544
View File
@@ -0,0 +1,544 @@
/**
* Bundle deals and coupon codes (1.51.9). Moved from bundleController.js and couponController.js
* so v1 and /api/v2 share them; matching codes and working out discounts stays in
* utils/coupons.js and utils/bundles.js.
*/
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 bundlesUtil = require('../utils/bundles') as {
KINDS: string[];
describeBundle(b: any): string;
describeBundleItems(b: any): string;
isBundleAvailable(b: any, now: Date): boolean;
};
const coupons = require('../utils/coupons') as {
normalizeCode(code: unknown): string;
previewCoupon(args: object): Promise<{ coupon: any; discount: number; bundles: { discount: number; applications: any[] } }>;
previewBundles(eventId: string, lines: any[]): Promise<{ discount: number; applications: any[] }>;
buildCouponLines(reg: any): any[];
describeCoupon(c: any): string;
countCouponUses(couponId: string, userId: string | null): Promise<{ usesTotal: number }>;
};
const pricing = require('../utils/pricing') as {
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
};
const campusScope = require('../utils/campusScope') as { couponAllowed(scope: object | null, campusIds: Array<string | null>): boolean };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role; email?: string };
export type Ctx = { scope: object | null; ip?: string };
const round2 = (n: unknown) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n: number) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
/** Plain errors from the parsers and helpers are the caller's mistake (400), like v1. */
async function clientErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'That could not be saved');
}
}
const optionalDate = (v: unknown, label: string): Date | null | undefined => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(String(v));
if (isNaN(d.getTime())) fail(400, `${label} is not a valid date`);
return d;
};
// ─── Pricing a basket (bundle quotes and coupon checks) ──────────────────────
/**
* Who a basket is being priced for: the signed-in buyer, or — for staff — the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
export async function basketBuyer(actor: Actor | null, forUserId: string | null | undefined) {
const isStaff = !!(actor && STAFF_ROLES.includes(actor.role));
let buyer: { id: string; role: string; email?: string } | null = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
if (isStaff && forUserId) buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
else if (isStaff && forUserId === null) buyer = null;
return { buyer, isStaff };
}
export type BasketLine = { eventOptionId: string; variantId?: string | null; quantity: number; sessionIds?: string[] };
/** Prices a basket the way registering will, as coupon/bundle lines. */
export async function priceBasket(event: { eventOptions: any[] } & Record<string, any>, options: BasketLine[], buyer: any) {
const registrationOptions: any[] = [];
// A "pick sessions" line counts once per chosen session.
const { isPickOption } = require('../utils/sessions') as { isPickOption(ev: any, opt: any): boolean };
const perSession = (o: BasketLine) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId ? await pricing.resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await pricing.resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }] });
}
return coupons.buildCouponLines({ registrationOptions });
}
const basketEvent = async (eventId: string) => {
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } });
if (!event) return fail(404, 'Event not found');
return event;
};
/** The bundle deals a basket gets, before any coupon. */
export function quoteBundles(input: { eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null) {
return clientErrors(async () => {
if (!input.eventId || !Array.isArray(input.options)) fail(400, 'eventId and options are required');
const event = await basketEvent(input.eventId!);
const { buyer } = await basketBuyer(actor, input.forUserId);
const lines = await priceBasket(event, input.options!, buyer);
const subtotal = round2(lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0));
const b = await coupons.previewBundles(event.id, lines);
return { subtotal, bundleDiscount: b.discount, bundles: b.applications, total: round2(Math.max(0, subtotal - b.discount)) };
});
}
export type CodeCheck =
| { valid: true; code: string; type: string; value: number; summary: string; description: string | null; discount: number; bundleDiscount: number; bundles: any[]; subtotal: number; total: number }
| { valid: false; message: string };
/**
* Checks a code against a basket. Staff can check on an attendee's behalf (`forUserId`), or
* leave it out for a new attendee — restricted codes are then checked when they register.
*/
export function checkCode(input: { code?: string; eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null): Promise<CodeCheck> {
return clientErrors(async () => {
if (!input.code || !input.eventId || !Array.isArray(input.options) || input.options.length === 0) fail(400, 'code, eventId and options are required');
const event = await basketEvent(input.eventId!);
const { buyer, isStaff } = await basketBuyer(actor, input.forUserId);
const lines = await priceBasket(event, input.options!, buyer);
const subtotal = lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount, bundles } = await coupons.previewCoupon({ code: input.code, eventId: event.id, user: buyer, lines, deferRestrictions: isStaff && !buyer });
return {
valid: true as const, code: coupon.code, type: coupon.type, value: coupon.value, summary: coupons.describeCoupon(coupon), description: coupon.description,
discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount),
};
} catch (e: any) {
if (!e.isCouponError) throw e;
return { valid: false as const, message: e.message };
}
});
}
// ─── Bundle deals ────────────────────────────────────────────────────────────
const GROUP_PRICING = ['per_ticket', 'percent'];
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
} as const;
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i: any) {
const variant = i.variantId ? (i.eventOption?.variants || []).find((v: any) => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
/** What the deal is worth at standard prices, and a short "save …" line (or null when the headline says it). */
function valueOf(bundle: any): { standardValue: number | null; savingText: string | null } {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m: number, i: any) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s: number, i: any) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
return { standardValue: null, savingText: null };
}
function shapeBundle(bundle: any, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }: any) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return { ...bundle, items, summary: bundlesUtil.describeBundleItems(bundle), headline: bundlesUtil.describeBundle(bundle), ...valueOf(bundle), available: bundlesUtil.isBundleAvailable(bundle, now) };
}
/**
* A create/update body as Prisma data plus the item list. On update, fields left out stay as
* they are; when any pricing field is sent, the whole deal (saved + new values) is checked.
*/
async function parseBundleBody(body: any, eventId: string, { isCreate, current = null }: { isCreate: boolean; current?: any }) {
const data: Record<string, any> = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) fail(400, 'Give the bundle a name');
if (name.length > 80) fail(400, 'Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Available from" must be before "Available until"');
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items: undefined };
const pick = (f: string) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!bundlesUtil.KINDS.includes(kind)) fail(400, 'Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map<string, { eventOptionId: string; variantId: string | null; quantity: number }>();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) fail(400, 'Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
const items = [...merged.values()];
if (items.length === 0) fail(400, kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) fail(400, 'A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) fail(400, 'At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) fail(400, 'Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) fail(400, 'Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) fail(400, 'Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) fail(400, 'Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({ where: { id: { in: items.map(i => i.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) fail(400, 'One of the tickets isn\'t part of this event');
if (i.variantId && !row!.variants.some(v => v.id === i.variantId)) fail(400, 'One of the selected variants no longer exists');
}
return { data, items };
}
const eventOr404 = async (eventId: string) => {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) return fail(404, 'Event not found');
return event;
};
/** An event's bundle deals with how often each has been used and the discount given. */
export async function listBundles(eventId: string) {
await eventOr404(eventId);
const bundles = await prisma.eventBundle.findMany({ where: { eventId }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
return bundles.map(b => ({
...shapeBundle(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
}));
}
export function createBundle(eventId: string, body: any, actor: Actor, ctx: Ctx) {
return clientErrors(async () => {
const event = await eventOr404(eventId);
const parsed = await parseBundleBody(body || {}, event.id, { isCreate: true });
const bundle = await prisma.eventBundle.create({ data: { ...(parsed.data as any), eventId: event.id, items: { create: parsed.items } }, include: DETAIL_INCLUDE });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'bundle_created', targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: bundlesUtil.describeBundle(bundle) }, ip: ctx.ip,
});
return { ...shapeBundle(bundle), uses: 0, registrations: 0, totalDiscount: 0 };
});
}
/** Edits a deal (or switches it off). Registrations that have it are re-worked only when their own items change. */
export function updateBundle(bundleId: string, body: any, actor: Actor, ctx: Ctx) {
return clientErrors(async () => {
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId }, include: { items: true } });
if (!existing) return fail(404, 'Bundle not found');
const parsed = await parseBundleBody(body || {}, existing.eventId, { isCreate: false, current: existing });
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'bundle_updated', targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(body || {}) }, ip: ctx.ip,
});
return shapeBundle(bundle);
});
}
/** Deletes a deal; one that's been used is switched off instead, so registrations keep their price. */
export async function deleteBundle(bundleId: string, actor: Actor, ctx: Ctx) {
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId } });
if (!existing) return fail(404, 'Bundle not found');
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
else await prisma.eventBundle.delete({ where: { id: existing.id } });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'bundle_deleted', targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 }, ip: ctx.ip,
});
return used > 0
? { switchedOff: true as const, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true as const, message: 'Bundle deleted' };
}
/** The deals shown on an event's page: available bundles, without internal fields. */
export async function publicBundlesForEvent(eventId: string) {
const bundles = await prisma.eventBundle.findMany({ where: { eventId, isActive: true }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
const now = new Date();
return bundles.filter(b => bundlesUtil.isBundleAvailable(b, now)).map(b => {
const s = shapeBundle(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map((i: any) => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
// ─── Coupon codes ────────────────────────────────────────────────────────────
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
} as const;
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
// Campus-scoped supervisors: only coupons limited to events at their campuses.
const couponCampuses = (coupon: any): Array<string | null> => (coupon.events || []).map((ce: any) => ce.event?.campusId ?? null);
async function eventIdsAllowed(scope: object | null, eventIds: string[] | undefined) {
if (!scope) return true;
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
return events.length === eventIds.length && campusScope.couponAllowed(scope, events.map(e => e.campusId));
}
const optionalInt = (v: unknown, label: string, min = 0): number | null | undefined => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) fail(400, `${label} must be a whole number of at least ${min}`);
return n;
};
/** A create/update body as Prisma data plus the event, ticket and person lists. On update, fields left out stay. */
async function parseCouponBody(body: any, { isCreate }: { isCreate: boolean }) {
const data: Record<string, any> = {};
if (isCreate || body.code !== undefined) {
const code = coupons.normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) fail(400, 'Codes are 3–32 letters, numbers, hyphens or underscores');
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) fail(400, "Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) fail(400, 'Enter a discount value greater than zero');
if (type === 'percent' && value > 100) fail(400, 'A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some((r: string) => !ROLES.includes(r))) fail(400, 'Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) fail(400, 'Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds: string[] | undefined = body.eventIds !== undefined ? [...new Set<string>((body.eventIds || []).filter(Boolean))] : undefined;
const scopes: Array<{ eventOptionId: string; variantId: string | null }> | undefined = body.options !== undefined
? (body.options || []).filter((o: any) => o && o.eventOptionId).map((o: any) => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds: string[] | undefined = body.userIds !== undefined ? [...new Set<string>((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) return fail(400, 'One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) fail(400, 'One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) fail(400, 'Selected ticket options must belong to the selected events');
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }: Awaited<ReturnType<typeof parseCouponBody>>, isCreate: boolean) {
const out: Record<string, any> = {};
const replace = isCreate ? {} : { deleteMany: {} };
if (eventIds !== undefined) out.events = { ...replace, create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...replace, create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...replace, create: userIds.map(userId => ({ userId })) };
return out;
}
/** Codes the caller may see, with how often each has been used and the discount given. */
export async function listCoupons(ctx: Ctx) {
const list = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } })).filter(c => campusScope.couponAllowed(ctx.scope, couponCampuses(c)));
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
return list.map(c => ({ ...c, summary: coupons.describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0 }));
}
/** One code with its redemptions. */
export async function getCoupon(couponId: string, ctx: Ctx) {
const coupon = await prisma.coupon.findUnique({
where: { id: couponId },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon || !campusScope.couponAllowed(ctx.scope, couponCampuses(coupon))) return fail(404, 'Coupon not found');
return { ...coupon, summary: coupons.describeCoupon(coupon) };
}
export function createCoupon(body: any, actor: Actor, ctx: Ctx) {
return clientErrors(async () => {
const parsed = await parseCouponBody(body || {}, { isCreate: true });
if (!(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
const coupon = await prisma.coupon.create({ data: { ...(parsed.data as any), createdById: actor.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE });
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: ctx.ip });
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: 0, totalDiscount: 0 };
});
}
/** Changes a code (or turns it off). Discounts already applied keep their locked amount; a used code can't be renamed. */
export function updateCoupon(couponId: string, body: any, actor: Actor, ctx: Ctx) {
return clientErrors(async () => {
const existing = await prisma.coupon.findUnique({ where: { id: couponId }, include: { events: { include: { event: { select: { campusId: true } } } } } });
if (!existing || !campusScope.couponAllowed(ctx.scope, couponCampuses(existing))) return fail(404, 'Coupon not found');
const parsed = await parseCouponBody({ type: existing.type, ...body }, { isCreate: false });
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
if (parsed.data.code && parsed.data.code !== existing.code) {
if (await prisma.couponRedemption.count({ where: { couponId: existing.id } }) > 0) fail(400, 'This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.');
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE });
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(body || {});
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: ctx.ip,
});
const { usesTotal } = await coupons.countCouponUses(coupon.id, null);
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: usesTotal };
});
}
/**
* Coupon and bundle-deal uses for the Discounts report, newest first. `scopeWhere` limits it to
* the caller's campuses (`{ event: … }` or `{}`). Bundle dates go by when the registration was made.
*/
export async function listRedemptions(opts: { eventIds: string[]; from: Date | null; to: Date | null }, scopeWhere: object) {
const range = (opts.from || opts.to) ? { createdAt: { ...(opts.from ? { gte: opts.from } : {}), ...(opts.to ? { lte: opts.to } : {}) } } : {};
const events = opts.eventIds.length ? { eventId: { in: opts.eventIds } } : {};
const rows = await prisma.couponRedemption.findMany({
where: { releasedAt: null, registration: { status: { not: 'cancelled' }, ...events, ...scopeWhere }, ...range },
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...events, ...range, ...scopeWhere } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
return [
...rows.map(r => ({ ...r, kind: 'coupon' as const, uses: 1, summary: coupons.describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id, kind: 'bundle' as const, uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: bundlesUtil.describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
];
}
+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;
+373
View File
@@ -0,0 +1,373 @@
/**
* One event in full, its ticket options and variants, attachments, calendar file and short link
* (1.51.8). Moved from eventController.js so /api/events/* (v1) and /api/v2/events/* share it.
* The pre-migration fallbacks (attachments kept in JSON manifests on disk, clients without the
* tier/variant models) are gone: every install has those tables.
*/
import fs from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
import { SESSIONS_INCLUDE, SERIES_SELECT, withSchedule } from './events';
import { publicBundlesForEvent } from './deals';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const sessionsUtil = require('../utils/sessions') as {
isPickOption(event: any, option: any): boolean;
activeSessions(sessions: any[]): Array<{ id: string }>;
sessionBookings(eventId: string): Promise<Map<string, number>>;
forgetSchedule(eventId: string): void;
};
const { publicOptions } = require('../utils/volunteers') as { publicOptions<O extends { id: string }>(event: any): O[] };
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { takenCount } = require('../utils/stock') as { takenCount(where: object): Promise<number> };
/** Where uploaded attachments live (served at /uploads/event-files/…). */
export const ATTACHMENTS_DIR = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
export const ALLOWED_DOCS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
/** A stored path as an absolute URL (the caller knows the host the request came to). */
export type UrlOf = (stored: string | null | undefined) => string | null;
/**
* Who's looking: staff of a campus they may see get everything (inactive and not-yet-live
* events, who created it, who gets its emails, the kids/volunteer tickets); everyone else gets
* the public view and NOT_FOUND for events that aren't public.
*/
export type Viewer = { staff: boolean; scope: object | null };
const { canView } = require('../utils/campusScope') as { canView(scope: object | null, campusId: string | null): boolean };
/** Staff see an event in full when it's at a campus they work with (or campuses are off for them). */
const staffViewOf = (viewer: Viewer, campusId: string | null) => viewer.staff && canView(viewer.scope, campusId);
const notPublic = (ev: { isActive: boolean | null; goLiveAt: Date | null }) =>
ev.isActive === false || (ev.goLiveAt != null && new Date() < new Date(ev.goLiveAt));
/**
* One event in full: ticket options with what's left (per variant, and per session for
* "pick sessions" tickets), sessions with bookings, the attendee form, attachments, bundle deals
* on sale and the next event in its series.
*/
export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: UrlOf) {
const event: any = await prisma.event.findUnique({
where: { id: eventId },
include: {
// (createdBy and notifyRecipients are dropped again below for the public view)
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
attachments: true,
...(viewer.staff ? {
createdBy: { select: { id: true, name: true, email: true } },
notifyRecipients: { select: { id: true, name: true, email: true, role: true } },
} : {}),
},
});
if (!event) return fail(404, 'Event not found');
if (!staffViewOf(viewer, event.campusId)) {
// Campus-scoped staff get the public view of other campuses' events.
delete event.createdBy;
delete event.notifyRecipients;
if (notPublic(event)) fail(404, 'Event not found');
// The kids check-in and volunteer tickets are handed out by staff, never sold.
event.eventOptions = publicOptions(event);
}
const f = await prisma.eventForm.findUnique({ where: { eventId }, include: { fields: true } });
const form = f ? {
id: f.id,
isRequired: !!f.isRequired,
fields: [...f.fields].sort((a, b) => (a.order || 0) - (b.order || 0)).map(fl => ({
id: fl.id, type: fl.type, label: fl.label, isRequired: !!fl.isRequired, order: fl.order || 0, helpText: fl.helpText || null, options: fl.options || null,
})),
} : null;
// What's left per option and variant (taken = non-cancelled registrations + places held for
// live waitlist offers). "Pick sessions" tickets have places per session instead.
const eventOptions = await Promise.all((event.eventOptions || []).map(async (opt: any) => {
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability: Record<string, number> = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const variants = await Promise.all((opt.variants || []).map(async (v: any) => {
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
}));
return { ...opt, soldCount, availableCount: opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null, variants };
}));
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const { nextInSeries } = require('../utils/series') as { nextInSeries(ev: unknown): Promise<unknown> };
return withSchedule({
...event,
eventOptions,
picture: urlOf(event.picture),
attachments: (event.attachments || []).map((att: any) => ({ ...att, url: urlOf(att.url) })),
form,
bundles: await publicBundlesForEvent(eventId).catch(() => []),
nextInSeries: await nextInSeries(event).catch(() => null),
}, bookings);
}
/** The event (or each session of a multi-day event) as an .ics calendar file. */
export async function eventCalendar(eventId: string, viewer: Viewer): Promise<{ filename: string; ics: string }> {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, campusId: true, sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event || (!staffViewOf(viewer, event.campusId) && notPublic(event))) return fail(404, 'Event not found');
const { buildEventIcs } = require('../utils/icsUtils') as { buildEventIcs(ev: unknown, url: string): string };
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// The ics library's URL check needs a scheme.
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) frontendUrl = `https://${frontendUrl}`;
return {
filename: `${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics`,
ics: buildEventIcs(event, `${frontendUrl}/events/${event.id}`),
};
}
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
/** An active event by its short link (`/camp2026`), any letter case. */
export async function eventByAlias(alias: string) {
if (!VALID_ALIAS.test(alias)) return fail(404, 'Event not found');
const event = await prisma.event.findFirst({
where: { redirectUrl: { equals: alias, mode: 'insensitive' }, isActive: true },
select: { id: true, title: true, description: true, startDate: true, endDate: true, registrationDeadline: true, goLiveAt: true, price: true, picture: true, isActive: true, redirectUrl: true },
});
if (!event) return fail(404, 'Event not found or inactive');
const now = new Date();
const isLive = (!event.startDate || new Date(event.startDate) >= now) && (!event.endDate || new Date(event.endDate) >= now);
return { ...event, isLive };
}
// ─── Ticket options and variants ─────────────────────────────────────────────
/** A staff price: undefined keeps `current`, '' or null clears it. */
export function parseStaffPrice(value: unknown, current: number | null = null): number | null {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(String(value));
if (!(n >= 0)) fail(400, 'Staff price must be zero or more');
return n;
}
const openEvent = async (eventId: string) => {
try {
await assertEventOpen(eventId);
} catch (e: any) {
fail(e.message === 'Event not found' ? 404 : 400, e.message);
}
};
export type OptionInput = {
name?: string; price?: number | string; isMainTicket?: boolean; stockLimit?: number | string; staffPrice?: number | string | null;
sessionMode?: string; earlyBirdTiers?: any[]; variants?: any[];
};
export async function createOption(eventId: string, input: OptionInput) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
await openEvent(eventId);
if (!input.name) fail(400, 'Give the ticket a name');
const option = await prisma.eventOption.create({
data: {
id: uuidv4(), eventId, name: String(input.name), price: parseFloat(String(input.price ?? 0)) || 0, isMainTicket: input.isMainTicket || false,
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0, staffPrice: parseStaffPrice(input.staffPrice),
sessionMode: input.sessionMode === 'pick' ? 'pick' : 'all',
},
});
sessionsUtil.forgetSchedule(eventId);
return option;
}
/**
* Changes a ticket option, and — when sent — its early-bird tiers and variants. Tiers and
* variants are upserted by id, so purchases keep pointing at the tier they were bought under;
* ones left out are removed only when nothing was bought under them.
*/
export async function updateOption(optionId: string, input: OptionInput) {
const existing = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!existing) return fail(404, 'Event option not found');
await openEvent(existing.eventId);
const updated = await prisma.eventOption.update({
where: { id: optionId },
data: {
name: input.name !== undefined ? String(input.name) : existing.name,
price: input.price !== undefined ? parseFloat(String(input.price)) : existing.price,
isMainTicket: input.isMainTicket !== undefined ? input.isMainTicket : existing.isMainTicket,
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : existing.stockLimit,
staffPrice: parseStaffPrice(input.staffPrice, existing.staffPrice),
sessionMode: input.sessionMode !== undefined ? (input.sessionMode === 'pick' ? 'pick' : 'all') : existing.sessionMode,
},
});
// Early-bird tiers: deleting and recreating them would give every tier a new id and cut past
// purchases off from the tier they were bought under (and reset its stock count).
if (Array.isArray(input.earlyBirdTiers)) {
const incoming = input.earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
for (const et of await prisma.earlyBirdTier.findMany({ where: { eventOptionId: optionId } })) {
if (incoming.includes(et.id)) continue;
// Never delete a tier still attributed on past purchases.
if (await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } }) === 0) {
await prisma.earlyBirdTier.delete({ where: { id: et.id } }).catch(() => {});
}
}
for (let i = 0; i < input.earlyBirdTiers.length; i++) {
const t = input.earlyBirdTiers[i];
if (!t || !t.deadline || t.price === undefined || t.price === null) continue;
const deadline = new Date(t.deadline);
const p = parseFloat(t.price);
if (isNaN(deadline.getTime()) || !(p >= 0)) continue;
const data = { eventOptionId: optionId, variantId: t.variantId || null, deadline, price: p, order: typeof t.order === 'number' ? t.order : i, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0 };
if (t.id) await prisma.earlyBirdTier.upsert({ where: { id: t.id }, update: data, create: { id: t.id, ...data } });
else await prisma.earlyBirdTier.create({ data: { id: uuidv4(), ...data } });
}
}
// Variants: upserted by id; ones left out are deleted only when nobody has bought them.
if (Array.isArray(input.variants)) {
const incoming = input.variants.filter(v => v.id).map(v => v.id);
for (const ov of await prisma.optionVariant.findMany({ where: { eventOptionId: optionId } })) {
if (!incoming.includes(ov.id) && await prisma.registrationOption.count({ where: { variantId: ov.id } }) === 0) {
await prisma.optionVariant.delete({ where: { id: ov.id } });
}
}
for (let i = 0; i < input.variants.length; i++) {
const v = input.variants[i];
if (!v.name) continue;
const data = {
name: v.name,
price: v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null,
stockLimit: v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0,
staffPrice: parseStaffPrice(v.staffPrice),
order: i,
};
if (v.id) await prisma.optionVariant.update({ where: { id: v.id }, data });
else await prisma.optionVariant.create({ data: { id: uuidv4(), eventOptionId: optionId, ...data } });
}
}
if (input.stockLimit !== undefined || input.variants !== undefined) {
(require('../utils/waitlist') as { queueWaitlist(eventId: string): void }).queueWaitlist(existing.eventId);
}
return prisma.eventOption.findUniqueOrThrow({ where: { id: updated.id }, include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } });
}
/** Removes a ticket option nobody has bought (and never the event's only main ticket). */
export async function deleteOption(optionId: string) {
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!option) return fail(404, 'Event option not found');
await openEvent(option.eventId);
if (option.isMainTicket) {
const others = await prisma.eventOption.count({ where: { eventId: option.eventId, isMainTicket: true, NOT: { id: option.id } } });
if (others === 0) fail(400, 'Cannot delete the only main ticket option');
}
if (await prisma.registrationOption.count({ where: { eventOptionId: optionId } }) > 0) fail(400, 'Cannot delete event option that has registrations');
await prisma.eventOption.delete({ where: { id: optionId } });
}
export type VariantInput = { name?: string; price?: number | string | null; stockLimit?: number | string; order?: number | string; staffPrice?: number | string | null };
export async function createVariant(optionId: string, input: VariantInput) {
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!option) return fail(404, 'Event option not found');
await openEvent(option.eventId);
if (!input.name) fail(400, 'Variant name is required');
return prisma.optionVariant.create({
data: {
id: uuidv4(), eventOptionId: optionId, name: String(input.name),
price: input.price !== undefined && input.price !== null && input.price !== '' ? parseFloat(String(input.price)) : null,
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0,
staffPrice: parseStaffPrice(input.staffPrice),
order: input.order !== undefined ? parseInt(String(input.order), 10) : 0,
},
});
}
export async function updateVariant(variantId: string, input: VariantInput) {
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) return fail(404, 'Variant not found');
await openEvent(variant.eventOption.eventId);
return prisma.optionVariant.update({
where: { id: variantId },
data: {
name: input.name !== undefined ? String(input.name) : variant.name,
staffPrice: parseStaffPrice(input.staffPrice, variant.staffPrice),
price: input.price !== undefined ? (input.price === null || input.price === '' ? null : parseFloat(String(input.price))) : variant.price,
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : variant.stockLimit,
order: input.order !== undefined ? parseInt(String(input.order), 10) : variant.order,
},
});
}
/** Removes a variant nobody has bought. */
export async function deleteVariant(variantId: string) {
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) return fail(404, 'Variant not found');
await openEvent(variant.eventOption.eventId);
if (await prisma.registrationOption.count({ where: { variantId } }) > 0) fail(400, 'Cannot delete variant with existing registrations');
await prisma.optionVariant.delete({ where: { id: variantId } });
}
// ─── Attachments ─────────────────────────────────────────────────────────────
export async function listAttachments(eventId: string, urlOf: UrlOf) {
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
return items.map(att => ({ ...att, url: urlOf(att.url) }));
}
/** Records a file multer has already saved in ATTACHMENTS_DIR. */
export async function recordAttachment(eventId: string, file: { originalname: string; filename: string; mimetype: string; size: number }, urlOf: UrlOf) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) {
fs.promises.unlink(path.join(ATTACHMENTS_DIR, file.filename)).catch(() => {});
return fail(404, 'Event not found');
}
const created = await prisma.eventAttachment.create({
data: { id: uuidv4(), eventId, originalName: file.originalname, filename: file.filename, mimeType: file.mimetype, size: file.size, url: `/uploads/event-files/${file.filename}` },
});
return { ...created, url: urlOf(created.url) };
}
export async function deleteAttachment(eventId: string, attachmentId: string) {
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
if (!att || att.eventId !== eventId) return fail(404, 'Attachment not found');
// The file goes too (best effort; the record is what the site shows).
await fs.promises.unlink(path.join(ATTACHMENTS_DIR, att.filename)).catch(() => {});
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
}
/** Admin diagnostics: whether the attachments table answers, and which database it is (no secrets). */
export async function attachmentsStatus() {
let tableQueryable = false;
let probeError: string | null = null;
try {
await prisma.eventAttachment.findFirst({});
tableQueryable = true;
} catch (e: any) {
probeError = String(e?.message || e);
}
let database: Record<string, string | undefined> | null = null;
try {
const u = new URL(process.env.DATABASE_URL || '');
database = { protocol: u.protocol.replace(':', ''), host: u.hostname, port: u.port || undefined, database: u.pathname.replace(/^\//, '') || undefined };
} catch { /* not a URL */ }
return { prismaClientHasEventAttachmentModel: true, databaseTableQueryable: tableQueryable, probeError, database };
}
/** Imports attachments still listed in old JSON manifests on disk into the database. */
export async function syncAttachmentManifests(opts: { dryRun: boolean; remove: boolean }) {
const { syncManifestsToDb } = require('../utils/attachmentsSync') as { syncManifestsToDb(db: unknown, o: { dryRun: boolean; removeManifestAfterImport: boolean }): Promise<Record<string, unknown>> };
return { ...opts, ...(await syncManifestsToDb(prisma, { dryRun: opts.dryRun, removeManifestAfterImport: opts.remove })) };
}
+411
View File
@@ -0,0 +1,411 @@
/**
* Setting events up (1.51.7): create, edit, deactivate, the staff list, notification recipients
* and copying set-up from another event. Moved from eventController.js so /api/events (v1) and
* /api/v2/events share it.
*/
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
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 */
type CampusScope = object | null;
const sessionsUtil = require('../utils/sessions') as {
sessionError(message: string): Error;
normalizeReminders(v: unknown): unknown;
normalizeCertificateConfig(v: unknown): unknown;
normalizeSessionsInput(v: unknown): Array<{ cancelled?: boolean } & Record<string, unknown>>;
scheduleBounds(sessions: unknown[]): { startDate: Date; endDate: Date };
forgetSchedule(eventId: string): void;
syncEventSessions(tx: any, eventId: string, sessions: unknown[]): Promise<{ sessions: Array<{ id: string; order: number; cancelledAt: Date | null }>; changes: Record<string, number> }>;
activeSessions(sessions: any[]): any[];
registrationClosesAt(ev: any, sessions: any[]): Date;
wholeCourseClosesAt(ev: any, sessions: any[]): Date;
lateInfo(ev: any, sessions: any[], now: Date): unknown;
};
const campuses = require('../utils/campuses') as {
campusesEnabled(): Promise<boolean>;
campusForEvent(campusId: unknown, opts: { enabled: boolean; currentCampusId?: string | null }): Promise<{ campusId?: string | null; error?: string }>;
campusWhere(filter: string, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
resolveCampusFilter(value: unknown): Promise<string>;
EVENT_CAMPUS_SELECT: Record<string, true>;
};
const campusScope = require('../utils/campusScope') as {
canManage(scope: object, campusId: string | null): boolean;
eventWhere(scope: CampusScope): object;
withScope(where: object, scopeWhere: object): object;
};
const seriesUtil = require('../utils/series') as { seriesForEvent(seriesId: unknown): Promise<string | null | undefined> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role };
/** The caller's campus scope (utils/campusScope.scopeFor) and IP, for checks and the audit log. */
export type Ctx = { scope: CampusScope; ip?: string };
/** An event body as the event wizard sends it (v1 and v2 take the same fields). */
export type EventInput = Record<string, any>;
export const SERIES_SELECT = { select: { id: true, name: true, slug: true } } as const;
export const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' as const }, include: { _count: { select: { usages: true } } } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
export function assertCampusAllowed(scope: CampusScope, campusId: string | null): void {
if (scope && !campusScope.canManage(scope, campusId)) {
fail(403, campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
}
}
/**
* Adds the multi-day schedule to an event for API responses: sessions (cancelled ones flagged),
* when registration closes, the next session, and late-joiner pricing. `bookings` (Map
* sessionId → people booked) adds `booked` and `placesLeft` per session.
*/
export function withSchedule<E extends { sessions?: any[]; scheduleType?: string }>(ev: E, bookings: Map<string, number> | null = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }: any) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find((s: any) => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
/** The schedule/attendance fields from a create/update body. `current` is the saved event on update. */
export function scheduleData(body: EventInput, current: Record<string, any> = {}): Record<string, any> {
const has = (k: string) => body[k] !== undefined;
const data: Record<string, any> = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session, inside the caller's transaction
* so a refused change saves nothing. Returns the change counts, or null when nothing was sent.
*/
export async function saveSchedule(tx: Prisma.TransactionClient, eventId: string, body: EventInput): Promise<Record<string, number> | null> {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
const yes = (v: unknown) => v === true || v === 'true';
const notNo = (v: unknown) => !(v === false || v === 'false');
/** Replaces the event's attendee form with the one sent (`{ isRequired, fields[] }`). */
async function saveForm(eventId: string, formInput: any): Promise<void> {
if (!formInput) return;
try {
let form = await prisma.eventForm.findUnique({ where: { eventId } });
if (!form) form = await prisma.eventForm.create({ data: { id: uuidv4(), eventId, isRequired: !!formInput.isRequired } });
else await prisma.eventForm.update({ where: { id: form.id }, data: { isRequired: !!formInput.isRequired } });
await prisma.eventFormField.deleteMany({ where: { formId: form.id } });
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(), formId: form.id, type: f.type, label: String(f.label), isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i, helpText: f.helpText || null, options: f.options || undefined,
},
});
}
} catch (e: any) {
console.error('[events] Failed to save form/fields:', e?.message || e);
fail(400, 'Failed to save event form/fields');
}
}
/** Turns a helper's plain Error (session rules, series, campus) into a 400, like v1 did. */
async function asClientErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'The event could not be saved');
}
}
/**
* A new event, with a "Main Ticket" at the event price (unless it's contact-only), its sessions
* and its attendee form. A campus-scoped supervisor must put it at one of their campuses.
*/
export function createEvent(body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled() });
if (campusPick.error) fail(400, campusPick.error);
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
assertCampusAllowed(ctx.scope, campusPick.campusId === undefined ? null : campusPick.campusId);
const seriesId = await seriesUtil.seriesForEvent(body.seriesId);
const data: Record<string, any> = {
id: uuidv4(),
title: body.title,
description: body.description,
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
goLiveAt: body.goLiveAt ? new Date(body.goLiveAt) : undefined,
price: parseFloat(body.price),
picture: body.picture,
updatedAt: new Date(),
createdById: actor.id,
redirectUrl: body.redirectUrl,
isHidden: yes(body.isHidden),
requiresAuth: notNo(body.requiresAuth),
requiresRegistration: notNo(body.requiresRegistration),
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: yes(body.allowNamedAttendees) || body.requiresParentalConsent === true,
requiresParentalConsent: body.requiresParentalConsent === true,
consentText: body.consentText ? String(body.consentText) : null,
consentCollectMedical: body.consentCollectMedical === true,
consentCollectEmergency: body.consentCollectEmergency === true,
consentCollectPhoto: body.consentCollectPhoto === true,
waitlistEnabled: body.waitlistEnabled === true,
allowTicketTransfer: body.allowTicketTransfer !== false,
collectGender: body.collectGender === true,
allowSponsorships: body.allowSponsorships === true,
sponsorshipGoalSpots: body.sponsorshipGoalSpots ? Math.max(1, parseInt(body.sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: body.showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(body.waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: body.waitlistPartialOffers !== false,
contactName: body.contactName || null,
contactPhone: body.contactPhone || null,
contactEmail: body.contactEmail || null,
location: body.location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...(seriesId ? { seriesId } : {}),
...scheduleData(body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const event = await prisma.event.create({ data: data as Prisma.EventUncheckedCreateInput });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'event_created', targetType: 'Event', targetId: event.id,
metadata: { title: event.title, ...(body.clonedFromId ? { clonedFromId: String(body.clonedFromId) } : {}) }, ip: ctx.ip,
});
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, body));
// A main ticket at the event price — contact-only events have nothing to book.
if (data.requiresRegistration !== false) {
await prisma.eventOption.create({ data: { id: uuidv4(), eventId: event.id, name: 'Main Ticket', price: event.price || 0, isMainTicket: true } })
.catch((e: any) => console.warn('[events] Failed to auto-create main ticket option:', e?.message || e));
}
await saveForm(event.id, body.form);
return withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE, eventOptions: true } }));
});
}
/**
* Edits an event: only the fields sent change. Its sessions save in the same transaction (a
* refused schedule change saves nothing); a changed consent text starts a new version; moving it
* to another series lets that series' followers hear about it. Closed (cashed-up) events can't
* change.
*/
export function updateEvent(eventId: string, body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return fail(404, 'Event not found');
// A closed (cashed-up) event's pricing/structure must not change under the reconciled totals.
await assertEventOpen(eventId);
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) fail(400, campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const seriesPick = await seriesUtil.seriesForEvent(body.seriesId);
// Moved into another series: that series' followers hear about it (once it's live).
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
if (campusChanged) assertCampusAllowed(ctx.scope, campusPick.campusId ?? null);
const keep = <K extends keyof typeof event>(k: K, v: unknown, map: (x: any) => unknown = x => x) => (v !== undefined ? map(v) : event[k]);
const requiresParentalConsent = body.requiresParentalConsent ?? event.requiresParentalConsent;
const data: Record<string, any> = {
title: body.title || event.title,
description: keep('description', body.description),
startDate: body.startDate ? new Date(body.startDate) : event.startDate,
endDate: body.endDate ? new Date(body.endDate) : event.endDate,
registrationDeadline: keep('registrationDeadline', body.registrationDeadline, v => (v ? new Date(v) : null)),
goLiveAt: body.goLiveAt !== undefined ? (body.goLiveAt ? new Date(body.goLiveAt) : new Date()) : (event.goLiveAt || undefined),
price: body.price ? parseFloat(body.price) : event.price,
picture: keep('picture', body.picture),
isActive: keep('isActive', body.isActive),
isHidden: body.isHidden !== undefined ? yes(body.isHidden) : (event.isHidden ?? false),
requiresAuth: body.requiresAuth !== undefined ? notNo(body.requiresAuth) : (event.requiresAuth ?? true),
requiresRegistration: body.requiresRegistration !== undefined ? notNo(body.requiresRegistration) : (event.requiresRegistration ?? true),
allowNamedAttendees: requiresParentalConsent === true ? true : (body.allowNamedAttendees !== undefined ? yes(body.allowNamedAttendees) : event.allowNamedAttendees),
requiresParentalConsent: keep('requiresParentalConsent', body.requiresParentalConsent, v => v === true),
consentCollectMedical: keep('consentCollectMedical', body.consentCollectMedical, v => v === true),
consentCollectEmergency: keep('consentCollectEmergency', body.consentCollectEmergency, v => v === true),
consentCollectPhoto: keep('consentCollectPhoto', body.consentCollectPhoto, v => v === true),
waitlistEnabled: keep('waitlistEnabled', body.waitlistEnabled, v => v === true),
allowTicketTransfer: keep('allowTicketTransfer', body.allowTicketTransfer, v => v !== false),
collectGender: keep('collectGender', body.collectGender, v => v === true),
allowSponsorships: keep('allowSponsorships', body.allowSponsorships, v => v === true),
sponsorshipGoalSpots: keep('sponsorshipGoalSpots', body.sponsorshipGoalSpots, v => (v ? Math.max(1, parseInt(v, 10) || 0) || null : null)),
showSponsorshipProgress: keep('showSponsorshipProgress', body.showSponsorshipProgress, v => v !== false),
waitlistOfferHours: keep('waitlistOfferHours', body.waitlistOfferHours, v => Math.min(168, Math.max(1, parseInt(v, 10) || 24))),
waitlistPartialOffers: keep('waitlistPartialOffers', body.waitlistPartialOffers, v => v !== false),
// Editing the consent text starts a new version; parents who already agreed keep their
// record of the old one (supervisors can re-request consent).
...(body.consentText !== undefined && String(body.consentText || '') !== String(event.consentText || '')
? { consentText: body.consentText ? String(body.consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: keep('contactName', body.contactName, v => v || null),
contactPhone: keep('contactPhone', body.contactPhone, v => v || null),
contactEmail: keep('contactEmail', body.contactEmail, v => v || null),
location: keep('location', body.location, v => v || null),
...(campusChanged ? { campusId: campusPick.campusId } : {}),
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
updatedAt: new Date(),
redirectUrl: keep('redirectUrl', body.redirectUrl),
...scheduleData(body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(body.sessions)) sessionsUtil.normalizeSessionsInput(body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : body;
let sessionChanges: Record<string, number> | null = null;
const saved = await prisma.$transaction(async (tx) => {
const updated = await tx.event.update({ where: { id: eventId }, data });
sessionChanges = updated.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single' ? await saveSchedule(tx, updated.id, scheduleBody) : null;
return updated;
});
sessionsUtil.forgetSchedule(saved.id);
if (campusChanged) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_campus_changed', targetType: 'Event', targetId: saved.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: ctx.ip });
}
const changes = sessionChanges as Record<string, number> | null;
if (changes && Object.values(changes).some(n => n > 0)) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_sessions_changed', targetType: 'Event', targetId: saved.id, metadata: changes, ip: ctx.ip });
}
await saveForm(saved.id, body.form);
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_updated', targetType: 'Event', targetId: eventId, metadata: { changedKeys: Object.keys(body || {}) }, ip: ctx.ip });
const fresh = withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: saved.id }, include: { sessions: SESSIONS_INCLUDE } }));
return { ...fresh, sessionChanges: changes };
});
}
/** "Deleting" an event deactivates it (registrations, payments and tickets stay). Admins only. */
export async function deactivateEvent(eventId: string, actor: Actor, ctx: Ctx) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({ where: { id: eventId }, data: { isActive: false, updatedAt: new Date() } });
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_deleted', targetType: 'Event', targetId: eventId, ip: ctx.ip });
return updated;
}
export type ManagedListOptions = { includePast?: boolean; includeInactive?: boolean; excludeClosed?: boolean; campus?: string };
/**
* Every event staff may see (hidden ones too) — upcoming by default; past, inactive and
* closed (cashed-up) on request; limited to their campuses when they're campus-scoped. With
* ticket options (tiers, variants), the attendee form, sessions, campus and series.
*/
export async function listManagedEvents(opts: ManagedListOptions, ctx: Ctx) {
const where: Record<string, any> = {};
if (!opts.includeInactive) where.isActive = true;
if (!opts.includePast) where.endDate = { gte: new Date() };
if (opts.excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campuses.campusesEnabled();
Object.assign(where, campuses.campusWhere(enabled ? await campuses.resolveCampusFilter(opts.campus) : '', { enabled }));
const scoped = campusScope.withScope(where, campusScope.eventWhere(ctx.scope));
const events = await prisma.event.findMany({
where: scoped,
include: {
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: campuses.EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
},
orderBy: { startDate: 'asc' },
});
return events.map(ev => withSchedule(ev));
}
const RECIPIENT_SELECT = { id: true, name: true, email: true, role: true } as const;
/** Who gets this event's registration, payment and daily-summary emails. */
export async function getNotifyRecipients(eventId: string) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { notifyRecipients: { select: RECIPIENT_SELECT } } });
if (!event) return fail(404, 'Event not found');
return event.notifyRecipients;
}
export async function setNotifyRecipients(eventId: string, userIds: string[]) {
if (!Array.isArray(userIds)) fail(400, 'userIds must be an array');
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({
where: { id: eventId },
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
include: { notifyRecipients: { select: RECIPIENT_SELECT } },
});
return updated.notifyRecipients;
}
/** Copies allocation groups (and, for admins, planned costs) from another event into this one. */
export async function copySetupFrom(eventId: string, fromEventId: string, actor: Actor) {
if (!fromEventId) fail(400, 'fromEventId is required');
const { copyEventSetup } = require('../utils/eventClone') as { copyEventSetup(from: string, to: string, opts: { withCosts: boolean }): Promise<unknown> };
return copyEventSetup(String(fromEventId), eventId, { withCosts: actor.role === 'admin' });
}
+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 });
}
File diff suppressed because it is too large Load Diff
+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;
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Event series and season passes (1.51.9). Moved from seriesController.js and
* seasonPassController.js so v1 and /api/v2 share them; the rules themselves stay in
* utils/series.js and utils/seasonPass.js.
*/
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 seriesUtil = require('../utils/series') as {
cleanSeriesInput(body: any, current?: any): any;
publicEventWhere(now?: Date): object;
splitEvents(events: any[]): { upcoming: any[]; past: any[] };
follow(seriesId: string, userId: string): Promise<unknown>;
unfollow(seriesId: string, userId: string): Promise<unknown>;
compareSeries(seriesId: string, opts: { scopeWhere: object }): Promise<unknown>;
};
const passes = require('../utils/seasonPass') as {
PASS_INCLUDE: any;
passView(pass: any, now?: Date): any;
cleanPassInput(body: any, seriesId: string, current?: any): Promise<{ data: any; items: any[] | undefined }>;
loadPass(id: string): Promise<any>;
buyPass(passId: string, userId: string, opts: object): Promise<any>;
startPassCheckout(purchaseId: string, userId: string, opts: object): Promise<any>;
};
const campusScope = require('../utils/campusScope') as {
canView(scope: object | null, campusId: string | null): boolean;
canManage(scope: object, campusId: string | null): boolean;
eventWhere(scope: object | null): object;
};
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role };
export type Ctx = { scope: object | null; ip?: string };
/** A taken web address is a CONFLICT with a message people understand. */
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e?.code === 'P2002') throw new ServiceError(409, 'Another series already uses that web address');
if (e instanceof ServiceError) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'The series could not be saved');
}
}
const auditSeries = (actor: Actor, ctx: Ctx, action: string, series: { id: string; name: string }, metadata: object = {}) => logAdminAction({
actorId: actor.id, actorRole: actor.role, action, targetType: 'EventSeries', targetId: series.id, metadata: { name: series.name, ...metadata }, ip: ctx.ip,
});
// ─── Series ──────────────────────────────────────────────────────────────────
/**
* Every series with its events and number of followers. A series spans campuses: campus-scoped
* staff see every series, but only their campuses' events in it.
*/
export async function listSeries(ctx: Ctx) {
const rows = await prisma.eventSeries.findMany({
orderBy: { name: 'asc' },
include: {
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
_count: { select: { followers: true, events: true } },
},
});
return rows.map(({ _count, events, ...s }) => ({
...s,
followers: _count.followers,
eventCount: _count.events,
events: events.filter(e => campusScope.canView(ctx.scope, e.campusId)),
}));
}
export function createSeries(body: object, actor: Actor, ctx: Ctx) {
return slugSafe(async () => {
const series = await prisma.eventSeries.create({ data: seriesUtil.cleanSeriesInput(body) });
auditSeries(actor, ctx, 'series_created', series);
return series;
});
}
export function updateSeries(seriesId: string, body: object, actor: Actor, ctx: Ctx) {
return slugSafe(async () => {
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId } });
if (!current) return fail(404, 'Series not found');
const data = seriesUtil.cleanSeriesInput(body, current);
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
auditSeries(actor, ctx, 'series_updated', series, { changedKeys: Object.keys(data) });
return series;
});
}
/** Deletes a series: its events stay (without a series) and followers are dropped. Not once passes are sold. */
export async function deleteSeries(seriesId: string, actor: Actor, ctx: Ctx) {
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId }, include: { _count: { select: { events: true, followers: true } } } });
if (!current) return fail(404, 'Series not found');
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
fail(409, 'People have bought season passes for this series, so it can\'t be deleted.');
}
await prisma.eventSeries.delete({ where: { id: current.id } });
auditSeries(actor, ctx, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
}
/** A series' public page: details, upcoming and past public events, its passes, and whether `userId` follows it. */
export async function seriesPage(slug: string, userId: string | null) {
const series = await prisma.eventSeries.findUnique({ where: { slug: String(slug) }, select: { id: true, name: true, slug: true, description: true, picture: true } });
if (!series) return fail(404, 'Series not found');
const events = await prisma.event.findMany({
where: { ...seriesUtil.publicEventWhere(), seriesId: series.id },
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
});
const { upcoming, past } = seriesUtil.splitEvents(events);
const following = userId ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId } } })) : false;
// Passes switched on; `unavailable` says why one can't be bought now.
const list = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE }))
.map(p => passes.passView(p))
.map(({ items, ...p }: any) => ({ ...p, items: items.map(({ campusId: _c, ...i }: any) => i) }));
return { ...series, upcoming, past, following, passes: list };
}
/** Year on year: each event in the series (that the caller may see), oldest first. */
export function compareSeries(seriesId: string, ctx: Ctx) {
return seriesUtil.compareSeries(seriesId, { scopeWhere: campusScope.eventWhere(ctx.scope) });
}
export async function setFollowing(seriesId: string, userId: string, following: boolean) {
try {
if (following) await seriesUtil.follow(seriesId, userId);
else await seriesUtil.unfollow(seriesId, userId);
} catch (e: any) {
throw new ServiceError(e?.statusCode || 400, e?.message || 'Could not change following');
}
return { following };
}
// ─── Season passes ───────────────────────────────────────────────────────────
const auditPass = (actor: Actor, ctx: Ctx, pass: { id: string; name: string }, action: string, metadata: object = {}) => logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id, metadata: { action, name: pass.name, ...metadata }, ip: ctx.ip,
});
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
function assertCampuses(ctx: Ctx, campusIds: Array<string | null>) {
if (ctx.scope && campusIds.some(c => !campusScope.canManage(ctx.scope!, c))) {
fail(403, 'You can only put events from your own campuses on a pass (not events for all campuses).');
}
}
const asServiceError = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
return await fn();
} catch (e: any) {
if (e instanceof ServiceError) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'Something went wrong with the pass');
}
};
/** A series' passes (or all) with their events, price shares, and how many were sold or are unpaid. */
export async function listPasses(seriesId?: string) {
const rows = await prisma.seasonPass.findMany({ where: seriesId ? { seriesId } : {}, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
const count = (passId: string, status: string) => sold.filter(s => s.passId === passId && s.status === status).reduce((n, s) => n + (s._sum.quantity || 0), 0);
return rows.map(p => ({ ...passes.passView(p), sold: count(p.id, 'paid'), unpaid: count(p.id, 'pending') }));
}
export function createPass(body: any, actor: Actor, ctx: Ctx) {
return asServiceError(async () => {
const seriesId = String(body?.seriesId || '');
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) fail(404, 'Series not found');
const { data, items } = await passes.cleanPassInput(body, seriesId);
assertCampuses(ctx, (items || []).map(i => i.campusId));
const pass = await prisma.seasonPass.create({ data: { ...data, seriesId, items: { create: (items || []).map(({ campusId: _c, ...i }) => i) } } });
auditPass(actor, ctx, pass, 'created', { events: (items || []).length, price: pass.price });
return passes.passView(await passes.loadPass(pass.id));
});
}
/** Changes a pass. Its events and price can't change once somebody has bought it. */
export function updatePass(passId: string, body: any, actor: Actor, ctx: Ctx) {
return asServiceError(async () => {
const current = await passes.loadPass(passId);
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
const { data, items } = await passes.cleanPassInput(body, current.seriesId, current);
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
fail(409, 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.');
}
if (items) assertCampuses(ctx, items.map(i => i.campusId));
await prisma.$transaction(async tx => {
await tx.seasonPass.update({ where: { id: current.id }, data });
if (items) {
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
await tx.seasonPassItem.createMany({ data: items.map(({ campusId: _c, ...i }) => ({ ...i, passId: current.id })) });
}
});
auditPass(actor, ctx, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
return passes.passView(await passes.loadPass(current.id));
});
}
/** Deletes a pass nobody has bought (otherwise switch it off). */
export function deletePass(passId: string, actor: Actor, ctx: Ctx) {
return asServiceError(async () => {
const current = await passes.loadPass(passId);
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) fail(409, 'People have bought this pass, so it can\'t be deleted — switch it off instead.');
await prisma.seasonPass.delete({ where: { id: current.id } });
auditPass(actor, ctx, current, 'deleted');
});
}
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const backTo = (slug: string) => {
const back = `${frontendUrl()}/series/${slug}`;
return { successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed` };
};
/** Buys passes: registers the buyer at every event on it, then a checkout (or tickets when it's free). */
export function buyPass(passId: string, userId: string, opts: { quantity?: number; provider?: string | null }) {
return asServiceError(async () => {
const pass = await prisma.seasonPass.findUnique({ where: { id: passId }, select: { series: { select: { slug: true } } } });
if (!pass) return fail(404, 'Season pass not found');
return passes.buyPass(passId, userId, { quantity: opts.quantity ?? 1, provider: opts.provider || null, ...backTo(pass.series.slug) });
});
}
/** Pays what's still owed on a pass purchase (a new checkout). The buyer only. */
export function payPassPurchase(purchaseId: string, userId: string, opts: { provider?: string | null }) {
return asServiceError(async () => {
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: purchaseId }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
if (!purchase || purchase.userId !== userId) return fail(404, 'Purchase not found');
return passes.startPassCheckout(purchaseId, userId, { provider: opts.provider || null, ...backTo(purchase.pass.series.slug) });
});
}
export function myPassPurchases(userId: string) {
return prisma.seasonPassPurchase.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
select: {
id: true, quantity: true, total: true, status: true, createdAt: true,
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
},
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Scanning sections and planned event costs (1.51.8). Moved from sectionController.js and
* costController.js so v1 and /api/v2 share them.
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const cashup = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown>; ALL_METHODS: string[] };
const openEvent = async (eventId: string) => {
try {
await cashup.assertEventOpen(eventId);
} catch (e: any) {
fail(e.message === 'Event not found' ? 404 : 400, e.message);
}
};
// ─── Sections (which ticket types may come in at which door) ─────────────────
const SECTION_INCLUDE = { allowedOptions: { include: { eventOption: true } } } as const;
/** Sections, for one event or every event the caller may see (`scopeWhere` from campus scope). */
export function listSections(eventId: string | undefined, scopeWhere: object) {
const where = { AND: [eventId && eventId !== 'all' ? { eventId } : {}, scopeWhere] };
return prisma.section.findMany({ where, include: { ...SECTION_INCLUDE, event: { select: { id: true, title: true } } }, orderBy: { name: 'asc' } });
}
export async function createSection(input: { eventId?: string; name?: string; allowedOptionIds?: string[] }) {
if (!input.eventId || !input.name) return fail(400, 'Event and section name are required');
const event = await prisma.event.findUnique({ where: { id: input.eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const section = await prisma.section.create({ data: { id: uuidv4(), eventId: input.eventId, name: String(input.name), updatedAt: new Date() } });
if (Array.isArray(input.allowedOptionIds) && input.allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId: section.id, eventOptionId: optionId })),
skipDuplicates: true,
});
}
return prisma.section.findUniqueOrThrow({ where: { id: section.id }, include: SECTION_INCLUDE });
}
/** Renames a section and/or replaces its ticket types (when `allowedOptionIds` is sent). */
export async function updateSection(sectionId: string, input: { name?: string; allowedOptionIds?: string[] }) {
const section = await prisma.section.findUnique({ where: { id: sectionId } });
if (!section) return fail(404, 'Section not found');
await prisma.section.update({ where: { id: sectionId }, data: { name: input.name || section.name, updatedAt: new Date() } });
if (Array.isArray(input.allowedOptionIds)) {
await prisma.sectionOption.deleteMany({ where: { sectionId } });
if (input.allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({ data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId, eventOptionId: optionId })) });
}
}
return prisma.section.findUniqueOrThrow({ where: { id: sectionId }, include: SECTION_INCLUDE });
}
export async function deleteSection(sectionId: string) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, select: { id: true } });
if (!section) return fail(404, 'Section not found');
await prisma.section.delete({ where: { id: sectionId } });
}
// ─── Planned costs (for cashup and the profit report) ────────────────────────
const COST_INCLUDE = { eventOption: { select: { id: true, name: true } } } as const;
function paidFromMethod(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (!cashup.ALL_METHODS.includes(String(value))) fail(400, "paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
return String(value);
}
export function listCosts(eventId: string) {
return prisma.eventCost.findMany({ where: { eventId }, include: COST_INCLUDE, orderBy: { createdAt: 'asc' } });
}
export type CostInput = { label?: string; costType?: string; amount?: number | string; eventOptionId?: string | null; notes?: string | null; paidFromMethod?: string | null };
/** A cost per event ("hall hire") or per ticket sold ("T-shirt per camper"). */
export async function createCost(eventId: string, input: CostInput) {
await openEvent(eventId);
if (!input.label || !String(input.label).trim()) fail(400, 'Label is required');
if (input.costType !== 'once_off' && input.costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
const amount = parseFloat(String(input.amount));
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
if (input.costType === 'per_item') {
if (!input.eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
const option = await prisma.eventOption.findUnique({ where: { id: input.eventOptionId! }, select: { eventId: true } });
if (!option || option.eventId !== eventId) fail(404, 'Ticket type not found for this event');
}
return prisma.eventCost.create({
data: {
id: uuidv4(), eventId, label: String(input.label).trim(), costType: input.costType as 'once_off' | 'per_item', amount,
eventOptionId: input.costType === 'per_item' ? input.eventOptionId! : null,
paidFromMethod: (paidFromMethod(input.paidFromMethod) ?? null) as any, notes: input.notes || null,
},
include: COST_INCLUDE,
});
}
export async function updateCost(costId: string, input: CostInput) {
const existing = await prisma.eventCost.findUnique({ where: { id: costId } });
if (!existing) return fail(404, 'Cost not found');
await openEvent(existing.eventId);
const costType = input.costType !== undefined ? input.costType : existing.costType;
if (costType !== 'once_off' && costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
const eventOptionId = costType === 'per_item' ? (input.eventOptionId !== undefined ? input.eventOptionId : existing.eventOptionId) : null;
if (costType === 'per_item') {
if (!eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId! }, select: { eventId: true } });
if (!option || option.eventId !== existing.eventId) fail(404, 'Ticket type not found for this event');
}
let amount = existing.amount;
if (input.amount !== undefined) {
amount = parseFloat(String(input.amount));
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
}
const paid = paidFromMethod(input.paidFromMethod);
return prisma.eventCost.update({
where: { id: costId },
data: {
label: input.label !== undefined ? String(input.label).trim() : existing.label,
costType: costType as 'once_off' | 'per_item',
amount,
eventOptionId,
paidFromMethod: (paid !== undefined ? paid : existing.paidFromMethod) as any,
notes: input.notes !== undefined ? input.notes : existing.notes,
},
include: COST_INCLUDE,
});
}
export async function deleteCost(costId: string) {
const existing = await prisma.eventCost.findUnique({ where: { id: costId }, select: { eventId: true } });
if (!existing) return fail(404, 'Cost not found');
await openEvent(existing.eventId);
await prisma.eventCost.delete({ where: { id: costId } });
}
+33 -5
View File
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 24 * 60 * 60 * 1000;
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
// includes that day. `search` matches the person (name or email), the target type or id,
// or the IP.
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (role) where.actorRole = role;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
if (from) where.createdAt.gte = startOf(from);
if (to) {
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
else where.createdAt.lte = new Date(to);
}
}
const q = String(search || '').trim().slice(0, 100);
if (q) {
const contains = { contains: q, mode: 'insensitive' };
where.OR = [
{ actor: { name: contains } },
{ actor: { email: contains } },
{ targetType: contains },
{ targetId: { startsWith: q.replace(/^#/, '') } },
{ ip: { startsWith: q } },
];
}
return where;
}
// Paginated listing for the admin audit-log page.
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
const where = auditLogWhere(filters);
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
+192
View File
@@ -0,0 +1,192 @@
/**
* Bulk actions on registrations (1.49.1): the registrations list lets a supervisor or admin tick
* many and act on them at once — mark paid, cancel, move to another ticket, put in a room or
* group, or send a message.
*
* Each action runs the same code as doing it one at a time (the controllers' own handlers, called
* in-process), so every rule still applies: closed events, payment caps and overpayments becoming
* donations, tickets and emails on payment, coupons released and the waitlist offered on
* cancelling, pricing on a ticket change, room capacity and gender, and the privacy rules and
* footers on messages. Results come back per registration; one failing doesn't stop the rest.
*/
const prisma = require('../config/db');
const MAX = 500;
const ACTIONS = ['markPaid', 'cancel', 'moveOption', 'allocate', 'message'];
const METHODS = ['cash', 'eft', 'card', 'other'];
function bulkError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Calls an Express handler in-process; resolves to { ok, status, body }. */
function call(handler, req) {
return new Promise((resolve) => {
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
send(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
};
Promise.resolve(handler({ query: {}, params: {}, body: {}, headers: {}, ip: req.ip, ...req }, res))
.catch(e => resolve({ ok: false, status: 500, body: { message: e?.message || String(e) } }));
});
}
/** Who is asking, with their request's headers and socket (so audit entries keep the real IP). */
const from = req => ({ user: req.user, headers: req.headers || {}, socket: req.socket });
const messageOf = r => r.body?.message || (r.ok ? 'Done' : `Failed (${r.status})`);
async function markPaid(req, regs, { method, paidAt }) {
if (!METHODS.includes(method)) throw bulkError('Choose how it was paid (cash, EFT, card or other)');
const { createPayment } = require('../controllers/paymentController');
const { refreshPricingForRegistration, computeRegistrationTotalDue } = require('./pricing');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled — not paid' }); continue; }
try { await refreshPricingForRegistration(r.id); } catch { /* the payment code refreshes too */ }
const fresh = await prisma.registration.findUnique({ where: { id: r.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } });
const owed = Math.round((computeRegistrationTotalDue(fresh, new Date()) - fresh.payments.reduce((s, p) => s + p.amount, 0)) * 100) / 100;
if (owed <= 0) { out.push({ id: r.id, ok: true, skipped: true, message: 'Already paid' }); continue; }
const res = await call(createPayment, { ...from(req), body: { amount: owed, method, registrationId: r.id, userId: r.userId, ...(paidAt ? { paidAt } : {}) } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? `R${owed.toFixed(2)} recorded` : messageOf(res) });
}
return out;
}
async function cancel(req, regs) {
const { cancelRegistration } = require('../controllers/registrationController');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: true, skipped: true, message: 'Already cancelled' }); continue; }
const res = await call(cancelRegistration, { ...from(req), params: { id: r.id } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? 'Cancelled' : messageOf(res) });
}
return out;
}
/**
* Moves every place on `fromOptionId` to `toOptionId`, keeping the same lines, tickets (and their
* QR codes) and named people — the editing screen can't take issued tickets away, but a move
* doesn't need to. Each moved line is repriced at the new ticket's current price (early birds and
* staff prices as for a new registration); the status follows what's now paid against owed, so
* moving up shows the difference owing and moving down shows the overpayment. Places on the new
* ticket are checked first.
*/
async function moveOption(req, regs, { fromOptionId, toOptionId }) {
if (!fromOptionId || !toOptionId || fromOptionId === toOptionId) throw bulkError('Choose the ticket to move from and the one to move to');
const options = await prisma.eventOption.findMany({ where: { id: { in: [fromOptionId, toOptionId] } }, include: { earlyBirdTiers: true, event: { select: { id: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true } } } });
if (options.length !== 2 || options[0].eventId !== options[1].eventId) throw bulkError('Both tickets must belong to the same event');
const target = options.find(o => o.id === toOptionId);
const event = target.event;
if (event.cashupStatus === 'closed') throw bulkError('This event is closed. Reopen it (admin only) before making changes.');
if ([event.kidsOptionId, event.volunteerOptionId].includes(toOptionId)) throw bulkError('The kids and volunteer tickets are handed out by staff, not moved to');
const { resolveOptionPrice } = require('./pricing');
const { remainingFor } = require('./stock');
const { updateRegistrationStatus } = require('../payments/webhook');
const out = [];
for (const r of regs) {
if (r.eventId !== event.id) { out.push({ id: r.id, ok: false, message: 'Not for this event' }); continue; }
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled' }); continue; }
if (r.seasonPassPurchaseId) { out.push({ id: r.id, ok: false, message: 'Part of a season pass — tickets can\'t be changed' }); continue; }
const lines = await prisma.registrationOption.findMany({ where: { registrationId: r.id, eventOptionId: fromOptionId, sessionId: null }, select: { id: true, quantity: true } });
if (!lines.length) { out.push({ id: r.id, ok: true, skipped: true, message: 'No such ticket to move' }); continue; }
const qty = lines.reduce((s, l) => s + l.quantity, 0);
const left = await remainingFor(target, null);
if (left !== null && left < qty) { out.push({ id: r.id, ok: false, message: `Only ${left} place${left === 1 ? '' : 's'} left on ${target.name}` }); continue; }
try {
const owner = await prisma.user.findUnique({ where: { id: r.userId }, select: { role: true } });
const { price, tierId } = await resolveOptionPrice(target, qty, owner);
await prisma.$transaction(async tx => {
for (const line of lines) {
await tx.registrationOption.update({ where: { id: line.id }, data: { eventOptionId: toOptionId, variantId: null, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.registrationOptionTranche.deleteMany({ where: { registrationOptionId: line.id } });
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: line.quantity, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.attendee.updateMany({ where: { registrationOptionId: line.id }, data: { eventOptionId: toOptionId, variantId: null } });
}
});
const updated = await updateRegistrationStatus(r.id);
const diff = Math.round(((updated.totalDue || 0) - (updated.totalPaid || 0)) * 100) / 100;
out.push({ id: r.id, ok: true, message: diff > 0 ? `Moved — R${diff.toFixed(2)} now owing` : diff < 0 ? `Moved — R${(-diff).toFixed(2)} overpaid` : 'Moved' });
} catch (e) {
out.push({ id: r.id, ok: false, message: e?.message || 'Couldn\'t move' });
}
}
return out;
}
async function allocate(req, regs, { setId, groupId }) {
if (!setId || !groupId) throw bulkError('Choose the group to put them in');
const { assign } = require('../controllers/allocationController');
const out = [];
for (const r of regs) {
const people = await prisma.attendee.findMany({ where: { registrationId: r.id }, select: { id: true, name: true } });
if (!people.length) { out.push({ id: r.id, ok: false, message: 'No named people on this registration to place' }); continue; }
const fails = [];
for (const a of people) {
const res = await call(assign, { ...from(req), params: { id: setId }, body: { attendeeId: a.id, groupId } });
if (!res.ok) fails.push(`${a.name}: ${messageOf(res)}`);
}
out.push({ id: r.id, ok: fails.length === 0, message: fails.length ? fails.join('; ') : `${people.length} placed` });
}
return out;
}
async function message(req, regs, { channel, subject, text }) {
if (!['email', 'whatsapp'].includes(channel)) throw bulkError('Choose email or WhatsApp');
if (!String(text || '').trim()) throw bulkError('Write the message');
if (channel === 'email' && !String(subject || '').trim()) throw bulkError('Give the email a subject');
const { emailEventAttendees, whatsappEventAttendees } = require('../controllers/eventController');
const byEvent = new Map();
for (const r of regs) byEvent.set(r.eventId, [...(byEvent.get(r.eventId) || []), r.id]);
const out = [];
for (const [eventId, ids] of byEvent) {
// An event update to the people ticked (they registered, so it goes even to those who opted out of news).
const body = channel === 'email'
? { subject, text, kind: 'update', filter: { registrationIds: ids } }
: { message: text, kind: 'update', filter: { registrationIds: ids } };
const res = await call(channel === 'email' ? emailEventAttendees : whatsappEventAttendees, { ...from(req), params: { id: eventId }, body });
for (const id of ids) out.push({ id, ok: res.ok, message: res.ok ? `Sent with ${res.body?.sent ?? res.body?.matched ?? ids.length} for this event` : messageOf(res) });
}
return out;
}
/**
* Runs one bulk action. Returns { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }.
*/
async function runBulk(req, { action, registrationIds, ...params }) {
if (!ACTIONS.includes(action)) throw bulkError('Unknown bulk action');
const ids = [...new Set((Array.isArray(registrationIds) ? registrationIds : []).map(String))];
if (!ids.length) throw bulkError('Tick at least one registration');
if (ids.length > MAX) throw bulkError(`At most ${MAX} at a time`);
// Read-only licence: no new payments or ticket changes (cancelling and messaging still work).
if (['markPaid', 'moveOption'].includes(action)) {
const { licenceStatus } = require('../hub/licence');
const status = await licenceStatus().catch(() => null);
if (status?.readOnly) throw bulkError('This site is read-only (licence), so payments and ticket changes can\'t be recorded.', 403);
}
const regs = await prisma.registration.findMany({ where: { id: { in: ids } }, include: { registrationOptions: { select: { eventOptionId: true, variantId: true, quantity: true, sessionId: true } } } });
const missing = ids.filter(id => !regs.some(r => r.id === id)).map(id => ({ id, ok: false, message: 'Not found' }));
const run = { markPaid, cancel, moveOption, allocate, message }[action];
const results = [...missing, ...(await run(req, regs, params))];
const { logAdminAction } = require('./adminAudit');
const { getClientIp } = require('./requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'registrations_bulk', targetType: 'Registration',
metadata: { bulk: action, registrations: ids.length, done: results.filter(r => r.ok && !r.skipped).length, failed: results.filter(r => !r.ok).length },
ip: getClientIp({ headers: {}, ...req }),
});
return {
action,
done: results.filter(r => r.ok && !r.skipped).length,
skipped: results.filter(r => r.skipped).length,
failed: results.filter(r => !r.ok).length,
results,
};
}
module.exports = { runBulk, ACTIONS, MAX };
+249
View File
@@ -0,0 +1,249 @@
/**
* The home dashboard for staff, supervisors and admins (1.49.0): what's on today, money in, and
* what's waiting for someone — plus the search behind the Ctrl+K command palette.
*
* Everything follows the person's campus scope (utils/campusScope.js): a campus-limited
* supervisor sees their campuses' events and church-wide ones. Items only some roles can act on
* (cashups, provider payments, privacy requests, webhooks) are shown to those roles only.
*/
const prisma = require('../config/db');
const { scopeFor, eventWhere } = require('./campusScope');
const DAY = 864e5;
// The same "real money" as the dashboards: everything except legs that apply an earlier donation.
const EXCLUDE_DONATION_LEGS = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
function dayBounds(now = new Date()) {
const start = new Date(now); start.setHours(0, 0, 0, 0);
return { start, end: new Date(start.getTime() + DAY - 1) };
}
const isAdmin = req => req.user?.role === 'admin';
const isSupervisor = req => ['admin', 'supervisor'].includes(req.user?.role);
/** Events on today (single-date events running today, or a session today), with how it's going. */
async function todaysEvents(scopeWhere, now = new Date()) {
const { start, end } = dayBounds(now);
const events = await prisma.event.findMany({
where: {
AND: [scopeWhere, { isActive: true }, {
OR: [
{ scheduleType: 'single', startDate: { lte: end }, endDate: { gte: start } },
{ scheduleType: 'sessions', sessions: { some: { startsAt: { gte: start, lte: end }, cancelledAt: null } } },
],
}],
},
orderBy: { startDate: 'asc' },
select: {
id: true, title: true, startDate: true, endDate: true, location: true, scheduleType: true, kidsCheckIn: true, isHidden: true,
campus: { select: { id: true, name: true, slug: true, colour: true } },
sessions: { where: { startsAt: { gte: start, lte: end }, cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
},
});
return Promise.all(events.map(async e => {
const session = e.sessions[0] || null;
const [places, scans, kidsIn, roles] = await Promise.all([
prisma.ticket.aggregate({ where: { eventId: e.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }),
prisma.ticketUsage.aggregate({ where: { ticket: { eventId: e.id }, scannedAt: { gte: start, lte: end } }, _sum: { quantityRedeemed: true } }),
e.kidsCheckIn ? prisma.kidsCheckIn.count({ where: { eventId: e.id, checkedOutAt: null, checkedInAt: { gte: start, lte: end } } }) : Promise.resolve(null),
prisma.servingRole.findMany({
where: { eventId: e.id, OR: [{ sessionId: null }, ...(session ? [{ sessionId: session.id }] : [])] },
select: { needed: true, slots: { select: { checkedInAt: true } } },
}),
]);
return {
id: e.id, title: e.title, location: e.location, campus: e.campus, isHidden: e.isHidden, kidsCheckIn: e.kidsCheckIn,
startsAt: session ? session.startsAt : e.startDate, endsAt: session ? session.endsAt : e.endDate,
sessionLabel: session ? session.label : null,
places: places._sum.quantity || 0,
checkedIn: scans._sum.quantityRedeemed || 0,
kidsIn,
volunteers: roles.length ? {
needed: roles.reduce((s, r) => s + r.needed, 0),
rostered: roles.reduce((s, r) => s + r.slots.length, 0),
checkedIn: roles.reduce((s, r) => s + r.slots.filter(x => x.checkedInAt).length, 0),
} : null,
};
}));
}
/** Money in today and over the last 7 days (the person's events, or everything for the unscoped). */
async function moneyIn(scope, now = new Date()) {
const { start } = dayBounds(now);
const weekStart = new Date(start.getTime() - 6 * DAY);
const where = from => ({ ...EXCLUDE_DONATION_LEGS, createdAt: { gte: from }, ...(scope ? { event: eventWhere(scope) } : {}) });
const [today, week] = await Promise.all([
prisma.payment.aggregate({ where: where(start), _sum: { amount: true }, _count: { _all: true } }),
prisma.payment.aggregate({ where: where(weekStart), _sum: { amount: true } }),
]);
return { today: today._sum.amount || 0, todayCount: today._count._all, week: week._sum.amount || 0 };
}
/**
* What's waiting for someone: each { key, label, count, href, tone }, only those with a count.
* `href` takes the person to where it's dealt with.
*/
async function pendingItems(req, scope, now = new Date()) {
const upcoming = { startDate: { gte: now } };
const scoped = (extra = {}) => ({ AND: [eventWhere(scope), extra] });
const soon = new Date(now.getTime() + 14 * DAY);
const week = new Date(now.getTime() + 7 * DAY);
const items = [];
const push = async (key, fn) => {
try {
const item = await fn();
if (item && item.count > 0) items.push({ key, ...item });
} catch (e) {
console.warn(`[dashboard] ${key}:`, e?.message || e);
}
};
await Promise.all([
push('unpaid', async () => ({
label: 'Unpaid or part-paid registrations for events in the next two weeks',
count: await prisma.registration.count({ where: { status: { in: ['pending', 'partial_paid'] }, event: scoped({ startDate: { gte: now, lte: soon }, isActive: true }) } }),
href: isAdmin(req) ? '/dashboard/admin/registrations?status=pending' : '/dashboard/supervisor/reports?report=regStatus',
tone: 'amber',
})),
push('consent', async () => {
const pending = await prisma.consentRequest.findMany({ where: { status: 'pending', event: scoped(upcoming) }, select: { attendeeId: true }, distinct: ['attendeeId'] });
const given = new Set((await prisma.consentRequest.findMany({ where: { status: 'given', attendeeId: { in: pending.map(p => p.attendeeId) } }, select: { attendeeId: true } })).map(g => g.attendeeId));
return { label: 'Young people still waiting for parental consent', count: pending.filter(p => !given.has(p.attendeeId)).length, href: '/dashboard/supervisor/consent', tone: 'rose' };
}),
push('waitlist', async () => ({
label: 'People on a waitlist',
count: await prisma.waitlistEntry.count({ where: { status: 'waiting', event: scoped(upcoming) } }),
href: '/dashboard/supervisor/waitlist',
tone: 'blue',
})),
push('cover', async () => ({
label: 'Volunteers asking for someone to cover their shift',
count: await prisma.volunteerSwap.count({ where: { status: 'open', role: { event: scoped({ endDate: { gte: now } }) } } }),
href: '/dashboard/supervisor/volunteers',
tone: 'violet',
})),
push('roster', async () => {
const roles = await prisma.servingRole.findMany({
where: {
event: scoped({ isActive: true }),
OR: [{ session: { startsAt: { gte: now, lte: week } } }, { sessionId: null, event: { startDate: { gte: now, lte: week } } }],
},
select: { needed: true, _count: { select: { slots: true } } },
});
return { label: 'Serving roles in the next week that still need people', count: roles.filter(r => r._count.slots < r.needed).length, href: '/dashboard/supervisor/volunteers', tone: 'violet' };
}),
isAdmin(req) && push('cashup', async () => ({
label: 'Past events still to cash up',
count: await prisma.event.count({ where: { cashupStatus: 'open', endDate: { lt: new Date(now.getTime() - DAY), gte: new Date(now.getTime() - 120 * DAY) }, payments: { some: {} } } }),
href: '/dashboard/admin/cashup',
tone: 'green',
})),
(isAdmin(req) || (isSupervisor(req) && !scope)) && push('reconcile', async () => ({
label: 'Online payments that didn\'t match a registration (reconcile them)',
count: await prisma.providerTransaction.count({ where: { reconciled: false, ignored: false } }),
href: '/dashboard/supervisor/payments',
tone: 'amber',
})),
isAdmin(req) && push('privacy', async () => ({
label: 'Privacy requests to answer',
count: await prisma.dataRequest.count({ where: { status: 'open' } }),
href: '/dashboard/admin/data-requests',
tone: 'rose',
})),
isAdmin(req) && push('webhooks', async () => ({
label: 'Webhook deliveries that failed this week',
count: await prisma.webhookDelivery.count({ where: { status: 'failed', createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
href: '/dashboard/admin/settings?tab=webhooks',
tone: 'rose',
})),
].filter(Boolean));
const order = ['privacy', 'consent', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks'];
return items.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
}
async function today(req, now = new Date()) {
const scope = await scopeFor(req);
const [events, money, pending] = await Promise.all([
todaysEvents(eventWhere(scope), now),
isSupervisor(req) ? moneyIn(scope, now) : Promise.resolve(null),
isSupervisor(req) ? pendingItems(req, scope, now) : Promise.resolve([]),
]);
return { events, money, pending };
}
// ─── Command palette search ──────────────────────────────────────────────────
/** "R450", "450", "r 450.50" → 450.5; anything else → null. Pure. */
function amountOf(q) {
const m = /^r?\s*(\d{1,7}(?:[.,]\d{1,2})?)$/i.exec(String(q || '').trim());
return m ? parseFloat(m[1].replace(',', '.')) : null;
}
/**
* Results for the command palette: events (everyone with dashboard access), and for supervisors
* payments by amount ("R450"); for admins also people and registrations. Each { type, id, title,
* subtitle, href }. Up to 5 of each kind.
*/
async function search(req, rawQuery, now = new Date()) {
const q = String(rawQuery || '').trim().slice(0, 80);
if (q.length < 2) return [];
const scope = await scopeFor(req);
const contains = { contains: q, mode: 'insensitive' };
const out = [];
const supervisor = isSupervisor(req);
const fmtDate = d => new Date(d).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' });
const events = await prisma.event.findMany({
where: { AND: [eventWhere(scope), { title: contains }] },
orderBy: { startDate: 'desc' }, take: 5,
select: { id: true, title: true, startDate: true, isActive: true, campus: { select: { name: true } } },
});
for (const e of events) {
out.push({
type: 'event', id: e.id, title: e.title,
subtitle: [fmtDate(e.startDate), e.campus?.name, e.isActive ? null : 'inactive'].filter(Boolean).join(' · '),
href: supervisor ? `/dashboard/supervisor/events?edit=${e.id}` : `/events/${e.id}`,
});
}
const amount = amountOf(q);
if (supervisor && amount !== null) {
const payments = await prisma.payment.findMany({
where: { amount: { in: [amount, -amount] }, createdAt: { gte: new Date(now.getTime() - 365 * DAY) }, ...(scope ? { event: eventWhere(scope) } : {}) },
orderBy: { createdAt: 'desc' }, take: 5,
select: { id: true, amount: true, method: true, createdAt: true, registrationId: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const p of payments) {
out.push({
type: 'payment', id: p.id, title: `R${p.amount.toFixed(2)} — ${p.user?.name || 'Unknown'}`,
subtitle: [p.method, p.event?.title, fmtDate(p.createdAt)].filter(Boolean).join(' · '),
href: isAdmin(req) && p.registrationId ? `/dashboard/admin/registrations?q=${p.registrationId}` : '/dashboard/supervisor/payments',
});
}
}
if (isAdmin(req)) {
const people = await prisma.user.findMany({
where: { isActive: true, OR: [{ name: contains }, { email: contains }, { phoneNumber: { contains: q.replace(/\s+/g, '') } }] },
orderBy: { name: 'asc' }, take: 5,
select: { id: true, name: true, email: true, role: true },
});
for (const u of people) {
out.push({ type: 'person', id: u.id, title: u.name, subtitle: [u.email, u.role !== 'user' ? u.role : null].filter(Boolean).join(' · '), href: `/dashboard/admin/users?q=${encodeURIComponent(u.email || u.name)}` });
}
// A registration by its reference (the start of its id), as on emails and invoices.
if (/^[0-9a-f-]{6,36}$/i.test(q)) {
const regs = await prisma.registration.findMany({
where: { id: { startsWith: q.toLowerCase() } }, take: 5,
select: { id: true, status: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const r of regs) {
out.push({ type: 'registration', id: r.id, title: `Registration ${r.id.slice(0, 8)} — ${r.user?.name || ''}`.trim(), subtitle: [r.event?.title, r.status].filter(Boolean).join(' · '), href: `/dashboard/admin/registrations?q=${r.id}` });
}
}
}
return out;
}
module.exports = { today, todaysEvents, moneyIn, pendingItems, search, amountOf, dayBounds };
+2
View File
@@ -467,6 +467,8 @@ async function anonymiseUser(userId, db = prisma) {
await tx.seriesFollow.deleteMany({ where: { userId } });
// Webhook deliveries about them (kept 30 days for the delivery log).
await tx.webhookDelivery.deleteMany({ where: { userId } });
// Their saved list filters (can hold search text, such as names).
await tx.savedFilter.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
* with one click; one per page can be their default (applied when the page opens). Stored per
* person on the server, so they follow them to any device. Only their own are ever read or changed.
*/
const prisma = require('../config/db');
// The list pages that have saved filters, and who can open them.
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
const MAX_PER_PAGE = 30;
const MAX_PARAMS_BYTES = 4000;
function filterError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function cleanPage(page) {
const p = String(page || '');
if (!PAGES[p]) throw filterError('Unknown list page');
return p;
}
function cleanName(name) {
const n = String(name || '').trim().replace(/\s+/g, ' ');
if (!n) throw filterError('Give the filter a name');
return n.slice(0, 60);
}
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
function cleanParams(params) {
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
const out = {};
for (const [k, v] of Object.entries(params)) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
if (typeof v === 'string') out[k] = v.slice(0, 500);
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
else if (typeof v === 'boolean') out[k] = v;
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
}
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
return out;
}
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
async function list(userId, page) {
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
return rows.map(view);
}
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
async function save(userId, { page, name, params, isDefault = false }) {
const p = cleanPage(page);
const n = cleanName(name);
const data = cleanParams(params);
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
}
return prisma.$transaction(async tx => {
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
const row = existing
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
return view(row);
});
}
async function own(userId, id) {
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
return row;
}
/** Rename, or make (or stop being) the default. */
async function update(userId, id, { name, isDefault }) {
const row = await own(userId, id);
return prisma.$transaction(async tx => {
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
try {
return view(await tx.savedFilter.update({
where: { id: row.id },
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
}));
} catch (e) {
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
throw e;
}
});
}
async function remove(userId, id) {
const row = await own(userId, id);
await prisma.savedFilter.delete({ where: { id: row.id } });
}
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
+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;
+137
View File
@@ -0,0 +1,137 @@
import type { RequestHandler } from 'express';
/**
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
*/
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
// v1's event detail is the full record (v2's /events/{id} is the public summary).
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/full` },
// 1.51.8: the rest of the events area
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/events\/by-alias\/([^/]+)$/, v2: m => `/api/v2/events/by-alias/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/options$/, v2: m => `/api/v2/events/${m[1]}/ticket-options` },
{ method: 'PUT', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/options\/([^/]+)\/variants$/, v2: m => `/api/v2/ticket-options/${m[1]}/variants` },
{ method: 'PUT', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/attachments\/status$/, v2: () => '/api/v2/attachments/status' },
{ method: 'POST', v1: /^\/api\/events\/attachments\/sync$/, v2: () => '/api/v2/attachments/sync' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)\/attachments\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attachments/${m[2]}` },
{ method: 'GET', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'POST', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'PUT', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'PUT', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'POST', v1: /^\/api\/uploads\/(event-image|logo|favicon)$/, v2: m => `/api/v2/uploads/${m[1]}` },
// 1.51.7: setting events up
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.10: managing registrations
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
// 1.51.9: series, season passes, bundles, coupons, campuses
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'GET', v1: /^\/api\/series\/by-slug\/([^/]+)$/, v2: m => `/api/v2/series/by-slug/${m[1]}` },
{ method: 'GET', v1: /^\/api\/series\/([^/]+)\/compare$/, v2: m => `/api/v2/series/${m[1]}/compare` },
{ method: 'POST', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'PUT', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'GET', v1: /^\/api\/season-passes\/mine$/, v2: () => '/api/v2/me/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/purchases\/([^/]+)\/checkout$/, v2: m => `/api/v2/season-pass-purchases/${m[1]}/checkout` },
{ method: 'POST', v1: /^\/api\/season-passes\/([^/]+)\/buy$/, v2: m => `/api/v2/season-passes/${m[1]}/buy` },
{ method: 'GET', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'PUT', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'POST', v1: /^\/api\/bundles\/quote$/, v2: () => '/api/v2/bundles/quote' },
{ method: 'GET', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'POST', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'PUT', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/coupons\/validate$/, v2: () => '/api/v2/coupons/check' },
{ method: 'GET', v1: /^\/api\/coupons\/redemptions$/, v2: () => '/api/v2/coupons/redemptions' },
{ method: 'GET', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'POST', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'GET', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'GET', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/admin$/, v2: () => '/api/v2/campuses/admin' },
{ method: 'GET', v1: /^\/api\/campuses\/by-slug\/([^/]+)$/, v2: m => `/api/v2/campuses/by-slug/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/campuses\/settings$/, v2: () => '/api/v2/campuses/enabled' },
{ method: 'PUT', v1: /^\/api\/campuses\/order$/, v2: () => '/api/v2/campuses/order' },
{ method: 'POST', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/([^/]+)\/audience$/, v2: m => `/api/v2/campuses/${m[1]}/audience` },
{ method: 'PUT', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
];
// GET /api/events/all isn't a single event.
const NOT_AN_EVENT_ID = new Set(['all']);
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
export function successorOf(method: string, path: string): string | null {
for (const s of SUCCESSORS) {
if (s.method !== method) continue;
const m = path.match(s.v1);
if (!m) continue;
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
return s.v2(m);
}
return null;
}
export const deprecationHeaders: RequestHandler = (req, res, next) => {
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
if (successor) {
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
}
next();
};
+112
View File
@@ -0,0 +1,112 @@
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>;
/** A more specific cause, e.g. CAMPUS_HAS_EVENTS or TWO_FACTOR_REQUIRED. */
reason?: 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) {
const out = new ApiError(code, e.message || 'Request failed.');
// A service's own code (not a Prisma one) says more precisely what happened.
if (typeof e.code === 'string' && e.code && !/^P\d{4}$/.test(e.code)) out.reason = e.code;
return out;
}
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 } : {}), ...(apiErr.reason ? { reason: apiErr.reason } : {}) } });
};
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();
};
+101
View File
@@ -0,0 +1,101 @@
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.
// eventsManage first: /events/manage must match before /events/:id.
import './resources/eventsManage';
import './resources/eventDetails';
import './resources/setup';
import './resources/seriesDeals';
import './resources/events';
import './resources/me';
import './resources/registrations';
import './resources/registrationsManage';
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;
};
+111
View File
@@ -0,0 +1,111 @@
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.upload
? { requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: [def.upload.field], properties: { [def.upload.field]: { type: 'string', format: 'binary', description: def.upload.description } } } } } } }
: def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
responses: {
[String(def.status ?? 200)]: def.file
? { description: 'The file', content: { [def.file.contentType]: { schema: { type: 'string' } } } }
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
...(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 };
},
});
+282
View File
@@ -0,0 +1,282 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, fileResponse, type ApiUser } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import * as details from '../../services/eventDetails';
import { managedEvent } from './eventsManage';
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
};
// The same multer step as v1 (field "file"; PDFs, Office files, text, CSV, ZIP; 20 MB).
const { receiveAttachment } = require('../../controllers/eventController') as { receiveAttachment: any };
const urlOf = (req: Request) => (u: string | null | undefined) => absoluteUrl(req, u);
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
const viewerOf = async (req: Request, user: ApiUser | null) => ({ staff: !!user && ['admin', 'supervisor', 'staff'].includes(user.role), scope: await campusScope.scopeFor(req) });
// ─── The event in full, calendar file, short link ──────────────────────────────
export const full = defineRoute({
method: 'get',
path: '/events/:id/full',
tag: 'Events',
summary: 'One event in full',
description: [
'What the registration page and the set-up screens need: ticket options with what\'s left (per variant, and per session for "pick sessions" tickets), early-bird tiers, sessions with bookings, the attendee form, attachments, bundle deals on sale and the next event in its series.',
'Staff (of a campus they work with) also get inactive and not-yet-live events, who created it, who gets its emails, and the kids/volunteer tickets. Everyone else gets NOT_FOUND for events that aren\'t public.',
].join('\n\n'),
access: 'optional',
params: idParams,
response: single(managedEvent.extend({
form: z.looseObject({ id, isRequired: z.boolean(), fields: z.array(z.looseObject({ id, type: z.string(), label: z.string() })) }).nullable(),
attachments: z.array(z.looseObject({ id, originalName: z.string(), url: z.string().nullable() })),
})),
async handler({ params, user, req }) {
return { data: plain(await details.getEventDetail(params.id, await viewerOf(req, user), urlOf(req))) };
},
});
export const calendar = defineRoute({
method: 'get',
path: '/events/:id/calendar.ics',
tag: 'Events',
summary: 'The event as a calendar file',
description: 'An .ics file (each session for a multi-day event). Same visibility as the event.',
access: 'optional',
file: { contentType: 'text/calendar' },
params: idParams,
response: fileResponse,
async handler({ params, user, req }) {
const { filename, ics } = await details.eventCalendar(params.id, await viewerOf(req, user));
return { data: { contentType: 'text/calendar; charset=utf-8', filename, body: ics } };
},
});
export const byAlias = defineRoute({
method: 'get',
path: '/events/by-alias/:alias',
tag: 'Events',
summary: 'An event by its short link',
description: 'The part after the site address (`/camp2026`), any letter case. Active events only.',
access: 'public',
params: z.object({ alias: z.string().min(1).max(100).describe('The short link, e.g. camp2026') }),
response: single(z.object({
id, title: z.string(), description: z.string().nullable(), startDate: isoDate, endDate: isoDate, registrationDeadline: isoDate.nullable(),
goLiveAt: isoDate.nullable(), price: z.number(), picture: z.string().nullable(), redirectUrl: z.string().nullable(),
isLive: z.boolean().describe('Not over yet'),
})),
async handler({ params, req }) {
const ev = plain(await details.eventByAlias(params.alias));
return { data: { ...ev, picture: absoluteUrl(req, ev.picture) } };
},
});
// ─── Ticket options and variants ─────────────────────────────────────────────
const variant = z.looseObject({ id, name: z.string(), price: z.number().nullable(), stockLimit: z.number().int(), staffPrice: z.number().nullable(), order: z.number().int() });
const tier = z.looseObject({ id, deadline: isoDate, price: z.number(), stockLimit: z.number().int(), variantId: id.nullable() });
const ticketOption = z.looseObject({
id, eventId: id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int(), staffPrice: z.number().nullable(),
sessionMode: z.enum(['all', 'pick']), earlyBirdTiers: z.array(tier).optional(), variants: z.array(variant).optional(),
});
const money = z.union([z.number(), z.string().max(20)]);
const optionBody = z.object({
name: z.string().trim().min(1).max(120),
price: money,
isMainTicket: z.boolean().optional(),
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional().describe('0 = no limit'),
staffPrice: money.nullable().optional().describe('What staff pay (empty = the normal price)'),
sessionMode: z.enum(['all', 'pick']).optional().describe('"pick" = buyers choose sessions (multi-day events)'),
});
const variantBody = z.object({
name: z.string().trim().min(1).max(120),
price: money.nullable().optional().describe('Empty = the option\'s price'),
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional(),
staffPrice: money.nullable().optional(),
order: z.union([z.number().int(), z.string().max(10)]).optional(),
});
export const createOption = defineRoute({
method: 'post',
path: '/events/:id/ticket-options',
tag: 'Events (staff)',
summary: 'Add a ticket option',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
status: 201,
params: idParams,
body: optionBody,
response: single(ticketOption),
async handler({ params, body }) {
return { data: plain(await details.createOption(params.id, body)) };
},
});
export const updateOption = defineRoute({
method: 'patch',
path: '/ticket-options/:id',
tag: 'Events (staff)',
summary: 'Change a ticket option',
description: 'Only what\'s sent changes. `earlyBirdTiers` and `variants`, when sent, are the full lists: kept by id, added without one, and ones left out removed when nothing was bought under them.',
access: 'supervisor',
campusScope: 'scopeEvent(eventOption, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
params: idParams,
body: optionBody.partial().extend({
earlyBirdTiers: z.array(z.looseObject({ id: id.optional(), deadline: z.string().max(40), price: money, stockLimit: z.union([z.number(), z.string()]).optional(), variantId: id.nullable().optional() })).max(50).optional(),
variants: z.array(variantBody.extend({ id: id.optional() })).max(100).optional(),
}),
response: single(ticketOption),
async handler({ params, body }) {
return { data: plain(await details.updateOption(params.id, body)) };
},
});
export const deleteOption = defineRoute({
method: 'delete',
path: '/ticket-options/:id',
tag: 'Events (staff)',
summary: 'Remove a ticket option',
description: 'Admins only. Not one anybody has bought, and never the event\'s only main ticket.',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteOption(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
export const createVariant = defineRoute({
method: 'post',
path: '/ticket-options/:id/variants',
tag: 'Events (staff)',
summary: 'Add a variant (e.g. a T-shirt size)',
access: 'supervisor',
campusScope: 'scopeEvent(eventOption, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
status: 201,
params: idParams,
body: variantBody,
response: single(variant),
async handler({ params, body }) {
return { data: plain(await details.createVariant(params.id, body)) };
},
});
export const updateVariant = defineRoute({
method: 'patch',
path: '/variants/:id',
tag: 'Events (staff)',
summary: 'Change a variant',
access: 'supervisor',
campusScope: 'scopeEvent(optionVariant, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'optionVariant', need: 'manage' })],
params: idParams,
body: variantBody.partial(),
response: single(variant),
async handler({ params, body }) {
return { data: plain(await details.updateVariant(params.id, body)) };
},
});
export const deleteVariant = defineRoute({
method: 'delete',
path: '/variants/:id',
tag: 'Events (staff)',
summary: 'Remove a variant',
description: 'Admins only, and not one anybody has bought.',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteVariant(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Attachments ─────────────────────────────────────────────────────────────
const attachment = z.object({ id, eventId: id, originalName: z.string(), filename: z.string(), mimeType: z.string(), size: z.number().int(), url: z.string().nullable(), createdAt: isoDate });
export const listAttachments = defineRoute({
method: 'get',
path: '/events/:id/attachments',
tag: 'Events',
summary: 'An event\'s attachments',
description: 'Files for attendees (programmes, packing lists…), newest first.',
access: 'public',
params: idParams,
response: z.object({ data: z.array(attachment) }),
async handler({ params, req }) {
return { data: plain(await details.listAttachments(params.id, urlOf(req))) };
},
});
export const uploadAttachment = defineRoute({
method: 'post',
path: '/events/:id/attachments',
tag: 'Events (staff)',
summary: 'Attach a file to an event',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' }), receiveAttachment],
upload: { field: 'file', description: 'PDF, Word, Excel, PowerPoint, text, CSV or ZIP, up to 20 MB' },
status: 201,
params: idParams,
response: single(attachment),
async handler({ params, req }) {
const r = req as Request & { multerError?: Error; file?: { originalname: string; filename: string; mimetype: string; size: number } };
if (r.multerError) throw new ApiError('VALIDATION_FAILED', r.multerError.message, { file: r.multerError.message });
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { file: 'Choose a file' });
return { data: plain(await details.recordAttachment(params.id, r.file, urlOf(req))) };
},
});
export const deleteAttachment = defineRoute({
method: 'delete',
path: '/events/:id/attachments/:attachmentId',
tag: 'Events (staff)',
summary: 'Remove an attachment',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: z.object({ id, attachmentId: id }),
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteAttachment(params.id, params.attachmentId);
return { data: { id: params.attachmentId, deleted: true as const } };
},
});
export const attachmentsStatus = defineRoute({
method: 'get',
path: '/attachments/status',
tag: 'Admin',
summary: 'Attachments diagnostics',
access: 'admin',
response: single(z.looseObject({ databaseTableQueryable: z.boolean(), probeError: z.string().nullable() })),
async handler() {
return { data: plain(await details.attachmentsStatus()) };
},
});
export const attachmentsSync = defineRoute({
method: 'post',
path: '/attachments/sync',
tag: 'Admin',
summary: 'Import attachments from old manifests',
description: 'Attachments recorded in JSON files on disk by very old versions go into the database. `dryRun` only reports; `remove` deletes each manifest once imported.',
access: 'admin',
body: z.object({ dryRun: z.boolean().default(false), remove: z.boolean().default(false) }),
response: single(z.looseObject({ dryRun: z.boolean(), remove: z.boolean() })),
async handler({ body }) {
return { data: plain(await details.syncAttachmentManifests(body)) };
},
});
+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 })),
},
};
},
});
+190
View File
@@ -0,0 +1,190 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import {
createEvent, updateEvent, deactivateEvent, listManagedEvents, getNotifyRecipients, setNotifyRecipients, copySetupFrom,
} from '../../services/events';
/* eslint-disable @typescript-eslint/no-explicit-any -- event records are passed through as stored */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeBody(targets: [string, string][], opts?: object): any;
};
/**
* Staff and supervisor views of events (1.51.7). Event records are large (ticket options with
* tiers and variants, the attendee form, sessions…) and grow with features, so the schema types
* the core fields and lets the rest through as stored (`looseObject`); dates come back as ISO
* strings. Registered before resources/events.ts so /events/manage isn't read as an event id.
*/
const option = z.looseObject({ id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int() });
const session = z.looseObject({ id, label: z.string().nullable(), startsAt: isoDate, endsAt: isoDate, cancelled: z.boolean() });
export const managedEvent = z.looseObject({
id,
title: z.string(),
description: z.string().nullable(),
startDate: isoDate,
endDate: isoDate,
isActive: z.boolean(),
isHidden: z.boolean(),
requiresRegistration: z.boolean(),
scheduleType: z.enum(['single', 'sessions']),
attendanceMode: z.enum(['whole', 'pick']),
cashupStatus: z.enum(['open', 'closed']),
campusId: id.nullable(),
seriesId: id.nullable(),
picture: z.string().nullable(),
eventOptions: z.array(option).optional(),
sessions: z.array(session).optional(),
});
/** A stored record as JSON (dates → ISO strings), with the picture as an absolute URL. */
const toJson = (req: Request, ev: any) => {
const plain = JSON.parse(JSON.stringify(ev));
if ('picture' in plain) plain.picture = absoluteUrl(req, plain.picture);
return plain;
};
const ctxOf = async (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { scope: await campusScope.scopeFor(req), ip: getClientIp(req) };
};
const actorOf = (user: ApiUser | null) => ({ id: user!.id, role: user!.role });
// The event wizard's fields. Only these are named here; the rest of the wizard's fields (consent,
// waitlist, sponsorship, sessions, late joining, certificates, form…) pass through as sent.
const eventBody = z.looseObject({
title: z.string().trim().min(1).max(200).optional(),
description: z.string().max(20000).nullable().optional(),
startDate: z.string().max(40).optional(),
endDate: z.string().max(40).optional(),
price: z.union([z.number(), z.string().max(20)]).optional(),
campusId: id.nullable().optional(),
seriesId: id.nullable().optional(),
scheduleType: z.enum(['single', 'sessions']).optional(),
sessions: z.array(z.looseObject({})).max(200).optional(),
});
export const listManaged = defineRoute({
method: 'get',
path: '/events/manage',
tag: 'Events (staff)',
summary: 'Every event staff can see',
description: 'Hidden ones too. Upcoming by default; `past`, `inactive` and `closed` (cashed-up) widen it. Campus-scoped staff see their campuses\' events and church-wide ones. Not paged — it\'s what the event pickers search.',
access: 'staff',
campusScope: 'scopeList (in listManagedEvents)',
query: z.object({
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include events that have ended'),
inactive: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include deactivated events'),
closed: z.enum(['true', 'false']).default('true').transform(v => v === 'true').describe('Include cashed-up events (default true)'),
campus: z.string().max(64).optional().describe("A campus id or slug, or 'all' for church-wide events only"),
}),
response: z.object({ data: z.array(managedEvent) }),
async handler({ query, req }) {
const events = await listManagedEvents({ includePast: query.past, includeInactive: query.inactive, excludeClosed: !query.closed, campus: query.campus }, await ctxOf(req));
return { data: events.map(ev => toJson(req, ev)) };
},
});
export const create = defineRoute({
method: 'post',
path: '/events',
tag: 'Events (staff)',
summary: 'Create an event',
description: 'Gets a "Main Ticket" at the event price (unless registration is off). A campus-scoped supervisor must choose one of their campuses.',
access: 'supervisor',
campusScope: 'campus checked in createEvent (assertCampusAllowed)',
status: 201,
body: eventBody.extend({ title: z.string().trim().min(1).max(200), startDate: z.string().max(40), endDate: z.string().max(40) }),
response: single(managedEvent),
async handler({ body, user, req }) {
return { data: toJson(req, await createEvent(body, actorOf(user), await ctxOf(req))) };
},
});
export const update = defineRoute({
method: 'patch',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Change an event',
description: 'Only the fields sent change. Sessions save with the event (a refused schedule change saves nothing). A cashed-up event can\'t change until an admin reopens it. The answer includes `sessionChanges` (added, moved, cancelled… counts) when sessions were sent.',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: eventBody,
response: single(managedEvent.extend({ sessionChanges: z.record(z.string(), z.number()).nullable() })),
async handler({ params, body, user, req }) {
return { data: toJson(req, await updateEvent(params.id, body, actorOf(user), await ctxOf(req))) };
},
});
export const remove = defineRoute({
method: 'delete',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Deactivate an event',
description: 'Admins only. The event is deactivated, not deleted: registrations, payments and tickets stay.',
access: 'admin',
params: idParams,
response: single(z.object({ id, isActive: z.literal(false) })),
async handler({ params, user, req }) {
const ev = await deactivateEvent(params.id, actorOf(user), await ctxOf(req));
return { data: { id: ev.id, isActive: false as const } };
},
});
export const copyFrom = defineRoute({
method: 'post',
path: '/events/:id/copy-from',
tag: 'Events (staff)',
summary: 'Copy set-up from another event',
description: 'Allocation groups, and planned costs for admins — used right after copying an event.',
access: 'supervisor',
campusScope: 'scopeEvent(manage) + scopeBody(fromEventId)',
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
params: idParams,
body: z.object({ fromEventId: id }),
response: single(z.looseObject({})),
async handler({ params, body, user }) {
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
},
});
const recipient = z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) });
export const listRecipients = defineRoute({
method: 'get',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Who gets this event\'s emails',
description: 'Registration, payment and daily-summary emails.',
access: 'supervisor',
campusScope: 'scopeEvent(view)',
middleware: [campusScope.scopeEvent()],
params: idParams,
response: z.object({ data: z.array(recipient) }),
async handler({ params }) {
return { data: await getNotifyRecipients(params.id) };
},
});
export const setRecipients = defineRoute({
method: 'put',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Set who gets this event\'s emails',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: z.object({ userIds: z.array(id).max(200) }),
response: z.object({ data: z.array(recipient) }),
async handler({ params, body }) {
return { data: await setNotifyRecipients(params.id, body.userIds) };
},
});
+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 } };
},
});
@@ -0,0 +1,214 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { pageOf, single } from '../paging';
import { id, idParams } from '../schemas';
import * as admin from '../../services/registrationAdmin';
/* eslint-disable @typescript-eslint/no-explicit-any -- registrations are passed through with their includes */
const campusScope = require('../../utils/campusScope') as {
listWhere(req: unknown, path?: string): Promise<object>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
};
const { notOnSeasonPass } = require('../../utils/seasonPass') as { notOnSeasonPass: any };
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
/** The v2 request as the registration service's Call. */
function callFrom(req: Request, user: ApiUser | null, parts: { params?: object; body?: object; query?: object }): admin.Call {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return {
actor: user ? { id: user.id, role: user.role, email: user.email } : null,
params: (parts.params ?? {}) as Record<string, string>,
body: parts.body ?? {},
query: parts.query ?? {},
readOnly: !!(req as any).readOnly,
ip: getClientIp(req),
listWhere: () => campusScope.listWhere(req, 'event'),
};
}
const OWNER_OR_STAFF = 'scopeEvent(registration, owner-ok)';
const ownerOrStaff = campusScope.scopeEvent({ resolve: 'registration', ownerOk: true });
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
/** A registration with everything the dashboards show: items and their prices, payments, attendees, coupons, `totalDueComputed`. */
const fullRegistration = z.looseObject({ id, status: z.enum(STATUSES), eventId: id, userId: id, totalDueComputed: z.number().optional() });
const message = single(z.looseObject({ message: z.string() }));
const lineBody = z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() });
const attendeeBody = z.looseObject({ id: id.optional().describe('An attendee already on it (left out = removed)'), eventOptionId: id, variantId: id.nullish(), name: z.string().max(200) });
const responsesBody = z.object({
responses: z.array(z.object({
attendeeId: id.nullish().describe('The named attendee this form is for'),
answers: z.record(z.string(), z.unknown()).describe('Field id → answer (stored as text)'),
})).max(500),
});
// ─── Owner (or staff) ────────────────────────────────────────────────────────
export const fullOne = defineRoute({
method: 'get', path: '/registrations/:id/full', tag: 'Registrations', summary: 'A registration with everything on it',
description: [
'Items and prices, payments, coupons and bundles, named attendees, form answers and the total due.',
'The owner, a named attendee or staff — or anyone with the link when not signed in (registration ids can\'t be guessed). Attendees\' contact details are only shown to the payer and staff.',
].join('\n\n'),
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, response: single(fullRegistration),
async handler({ params, user, req }) { return { data: plain(await admin.getRegistrationById(callFrom(req, user, { params }))) }; },
});
export const myFull = defineRoute({
method: 'get', path: '/me/registrations/full', tag: 'Me', summary: 'My registrations with everything on them',
description: 'What the dashboard shows: as `/registrations/{id}/full`, newest first, with the event and its form. Only upcoming ones unless `past=true`.',
access: 'user',
query: z.object({ past: z.enum(['true', 'false']).default('false') }),
response: z.object({ data: z.array(fullRegistration) }),
async handler({ query, user, req }) { return { data: plain(await admin.getUserRegistrations(callFrom(req, user, { query: { showPast: query.past } }))) }; },
});
export const changeItems = defineRoute({
method: 'put', path: '/registrations/:id/items', tag: 'Registrations', summary: 'Change the tickets on a registration',
description: [
'The whole list of items it should have. Added places need stock and are priced now; places already paid for keep their price, and nothing goes below tickets already issued or the amount paid.',
'Not for registrations made with a season pass, nor once the event has ended or is closed. Staff can add kids and volunteer places; owners can only keep or lower them.',
].join('\n\n'),
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
body: z.object({ options: z.array(lineBody.omit({ sessionIds: true })).min(1).max(100) }), response: single(fullRegistration),
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationOptions(callFrom(req, user, { params, body }))) }; },
});
export const sendInvoice = defineRoute({
method: 'post', path: '/registrations/:id/invoice', tag: 'Registrations', summary: 'Send me the invoice again',
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
body: z.object({ channel: z.enum(['email', 'whatsapp']).default('email') }), response: message,
async handler({ params, body, user, req }) { return { data: await admin.sendInvoice(callFrom(req, user, { params, body })) }; },
});
export const applyCoupon = defineRoute({
method: 'post', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Apply a coupon code',
description: 'By the owner, or by staff on their behalf (logged). Not on season pass registrations.',
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
body: z.object({ code: z.string().trim().min(1).max(40) }), response: single(fullRegistration.extend({ message: z.string() })),
async handler({ params, body, user, req }) { return { data: plain(await admin.applyCoupon(callFrom(req, user, { params, body }))) }; },
});
export const removeCoupon = defineRoute({
method: 'delete', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Take a coupon code off',
description: 'Staff. Its use is freed. Not once a ticket on it has been scanned; unused tickets are withdrawn if it\'s no longer paid.',
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, response: message,
async handler({ params, user, req }) { return { data: await admin.removeCoupon(callFrom(req, user, { params })) }; },
});
export const setAttendees = defineRoute({
method: 'put', path: '/registrations/:id/attendees', tag: 'Registrations', summary: 'Name the people on a registration',
description: 'The whole list: attendees left out are removed (not once their ticket has been scanned). `ticketDelivery` says who gets the tickets.',
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
body: z.object({ attendees: z.array(attendeeBody).max(1000), ticketDelivery: z.enum(['payer', 'each']).optional() }),
response: single(z.looseObject({ id, ticketDelivery: z.string().nullable(), attendees: z.array(z.looseObject({ id, name: z.string() })) })),
async handler({ params, body, user, req }) { return { data: plain(await admin.updateAttendees(callFrom(req, user, { params, body }))) }; },
});
export const sendFormLink = defineRoute({
method: 'post', path: '/registrations/:id/attendees/:attendeeId/form-link', tag: 'Registrations', summary: 'Send an attendee their form',
description: 'A link to fill in their own attendee form, by email and/or WhatsApp.',
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: z.object({ id, attendeeId: id }), response: message,
async handler({ params, user, req }) { return { data: await admin.sendAttendeeFormLink(callFrom(req, user, { params })) }; },
});
export const getDraft = defineRoute({
method: 'get', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Attendee forms saved part-way',
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
response: single(z.object({ data: z.record(z.string(), z.unknown()), updatedAt: z.string().nullable() })),
async handler({ params, user, req }) { return { data: plain(await admin.getFormDraft(callFrom(req, user, { params }))) }; },
});
export const saveDraft = defineRoute({
method: 'put', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Save attendee forms part-way',
description: 'Kept for the registration\'s owner (also when staff save it), until the forms are submitted.',
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
body: z.object({ data: z.record(z.string(), z.unknown()) }), response: single(z.object({ message: z.string(), updatedAt: z.string() })),
async handler({ params, body, user, req }) { return { data: plain(await admin.saveFormDraft(callFrom(req, user, { params, body }))) }; },
});
export const submitForms = defineRoute({
method: 'post', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Submit attendee forms',
description: 'One response per main ticket at most; each named attendee answers once. Tickets are issued once it\'s paid and the forms are in.',
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, body: responsesBody,
response: single(z.looseObject({ message: z.string(), count: z.number().int().optional() })),
async handler({ params, body, user, req }) { return { data: plain(await admin.submitFormResponses(callFrom(req, user, { params, body }))) }; },
});
export const replaceForms = defineRoute({
method: 'put', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Replace the attendee forms',
description: 'Supervisors: every response on the registration is replaced by these.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, body: responsesBody, response: message,
async handler({ params, body, user, req }) { return { data: await admin.replaceFormResponses(callFrom(req, user, { params, body })) }; },
});
// ─── Staff ───────────────────────────────────────────────────────────────────
export const list = defineRoute({
method: 'get', path: '/registrations', tag: 'Registrations', summary: 'Every registration (staff)',
description: 'Newest first, for the events you may see. `unpaid=true` for ones still owing; `userId` for one person\'s.',
access: 'staff', campusScope: 'scopeList (listWhere)',
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(200).default(100).describe('At most 200'),
unpaid: z.enum(['true', 'false']).default('false'),
userId: id.optional(),
}),
response: z.object({ data: z.array(fullRegistration), meta: z.object({ page: z.number().int(), limit: z.number().int(), total: z.number().int(), pages: z.number().int() }) }),
async handler({ query, user, req }) {
const out = await admin.getRegistrations(callFrom(req, user, { query: { page: String(query.page), limit: String(query.limit), unpaid: query.unpaid === 'true' ? '1' : '', userId: query.userId } }));
return pageOf(plain(out.data), out.total, { page: out.page, limit: out.limit });
},
});
export const forEvent = defineRoute({
method: 'get', path: '/events/:id/registrations', tag: 'Registrations', summary: 'An event\'s registrations',
description: 'Oldest first, with tickets, payments and attendees. `search` matches the name, email or phone.',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
query: z.object({ search: z.string().max(200).optional() }),
response: z.object({ data: z.array(fullRegistration) }),
async handler({ params, query, user, req }) { return { data: plain(await admin.getRegistrationsByEvent(callFrom(req, user, { params: { eventId: params.id }, query }))) }; },
});
export const setStatus = defineRoute({
method: 'patch', path: '/registrations/:id/status', tag: 'Registrations', summary: 'Set a registration\'s status',
description: 'Staff. `paid` issues and sends the tickets; leaving `paid` withdraws unused tickets (refused once one has been scanned); `cancelled` frees its places for the waitlist.',
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.object({ status: z.enum(STATUSES) }), response: single(fullRegistration),
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationStatus(callFrom(req, user, { params, body }))) }; },
});
export const manual = defineRoute({
method: 'post', path: '/registrations/manual', tag: 'Registrations', summary: 'Register someone (at the door, by phone)',
description: [
'Supervisors. Finds the person by email or phone (refused when those are two different accounts) or makes an account and sends them an activation link (`skipActivationNotice` to not).',
'Added to their existing registration for the event if they have one. Free ones are marked paid and get their tickets; otherwise they\'re sent a link to pay.',
].join('\n\n'),
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })], status: 201,
body: z.looseObject({
eventId: id,
options: z.array(lineBody).min(1).max(100),
user: z.looseObject({ name: z.string().trim().min(1).max(200), email: z.string().max(200).nullish(), phoneNumber: z.string().max(40).nullish(), password: z.string().max(200).optional().describe('Self-service kiosk: the visitor sets it, and the account is active at once') }),
notificationPreference: z.string().max(20).nullish().describe('email, whatsapp or both (anything else: worked out from the contact details given)'),
skipActivationNotice: z.boolean().optional(),
couponCode: z.string().max(40).nullish(),
attendees: z.array(attendeeBody).max(1000).optional(),
ticketDelivery: z.enum(['payer', 'each']).optional(),
}),
response: single(fullRegistration.extend({ couponError: z.string().optional() })),
async handler({ body, user, req }) { return { data: plain(await admin.createManualRegistration(callFrom(req, user, { body }))) }; },
});
export const bulk = defineRoute({
method: 'post', path: '/registrations/bulk', tag: 'Registrations', summary: 'Do one thing to many registrations',
description: '`markPaid`, `cancel`, `moveOption`, `allocate` or `message`, for up to 500 you may manage. Each is done on its own: the answer lists what worked and what didn\'t.',
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
response: single(z.looseObject({})),
async handler({ body, req }) {
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
return { data: plain(await runBulk(req, body)) };
},
});
+365
View File
@@ -0,0 +1,365 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import * as series from '../../services/series';
import * as deals from '../../services/deals';
import * as campuses from '../../services/campuses';
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<any>;
listWhere(req: unknown, path?: string): Promise<object>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
};
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
const ctxOf = async (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { scope: await campusScope.scopeFor(req), ip: getClientIp(req) };
};
const actorOf = (user: ApiUser | null) => ({ id: user!.id, role: user!.role, email: user!.email });
const deleted = single(z.object({ id, deleted: z.literal(true) }));
const SPANS = 'campusIndependent (a series spans campuses; events are checked when they join it)';
// ─── Series ──────────────────────────────────────────────────────────────────
const seriesRecord = z.looseObject({ id, name: z.string(), slug: z.string(), description: z.string().nullable(), picture: z.string().nullable() });
const seriesBody = z.object({
name: z.string().trim().min(1).max(120),
slug: z.string().trim().max(80).optional().describe('The web address part (/series/<slug>); made from the name when left out'),
description: z.string().max(5000).nullable().optional(),
picture: z.string().max(500).nullable().optional(),
});
export const listSeries = defineRoute({
method: 'get', path: '/series', tag: 'Series', summary: 'Every series',
description: 'With its events (the ones you may see — a series spans campuses), followers and event count.',
access: 'supervisor', campusScope: 'filters each series\' events by campus (listSeries)',
response: z.object({ data: z.array(seriesRecord.extend({ followers: z.number().int(), eventCount: z.number().int(), events: z.array(z.looseObject({ id, title: z.string(), startDate: isoDate })) })) }),
async handler({ req }) { return { data: plain(await series.listSeries(await ctxOf(req))) }; },
});
export const createSeries = defineRoute({
method: 'post', path: '/series', tag: 'Series', summary: 'Create a series',
access: 'supervisor', campusScope: SPANS, status: 201, body: seriesBody, response: single(seriesRecord),
async handler({ body, user, req }) { return { data: plain(await series.createSeries(body, actorOf(user), await ctxOf(req))) }; },
});
export const updateSeries = defineRoute({
method: 'patch', path: '/series/:id', tag: 'Series', summary: 'Change a series',
access: 'supervisor', campusScope: SPANS, params: idParams, body: seriesBody.partial(), response: single(seriesRecord),
async handler({ params, body, user, req }) { return { data: plain(await series.updateSeries(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const deleteSeries = defineRoute({
method: 'delete', path: '/series/:id', tag: 'Series', summary: 'Delete a series',
description: 'Admins. Its events stay (without a series) and followers are dropped. Not once season passes have been sold.',
access: 'admin', params: idParams, response: deleted,
async handler({ params, user, req }) { await series.deleteSeries(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
});
export const seriesPage = defineRoute({
method: 'get', path: '/series/by-slug/:slug', tag: 'Series', summary: 'A series\' public page',
description: 'Upcoming and past public events, season passes on sale, and whether you follow it.',
access: 'optional', params: z.object({ slug: z.string().min(1).max(80) }),
response: single(seriesRecord.extend({ upcoming: z.array(z.looseObject({ id })), past: z.array(z.looseObject({ id })), following: z.boolean(), passes: z.array(z.looseObject({ id, name: z.string(), price: z.number() })) })),
async handler({ params, user, req }) {
const page = plain(await series.seriesPage(params.slug, user?.id ?? null));
return { data: { ...page, picture: absoluteUrl(req, page.picture) } };
},
});
export const compareSeries = defineRoute({
method: 'get', path: '/series/:id/compare', tag: 'Series', summary: 'Year on year',
description: 'Registrations, tickets, income and attendance for each event in the series that you may see, oldest first.',
access: 'supervisor', campusScope: 'scopeList (eventWhere in compareSeries)', params: idParams, response: single(z.looseObject({})),
async handler({ params, req }) { return { data: plain(await series.compareSeries(params.id, await ctxOf(req))) }; },
});
export const follow = defineRoute({
method: 'put', path: '/series/:id/follow', tag: 'Series', summary: 'Follow a series',
description: 'Hear about new events in it (News & announcements).',
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, true) }; },
});
export const unfollow = defineRoute({
method: 'delete', path: '/series/:id/follow', tag: 'Series', summary: 'Stop following a series',
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, false) }; },
});
// ─── Season passes ───────────────────────────────────────────────────────────
const pass = z.looseObject({ id, seriesId: id, name: z.string(), price: z.number(), isActive: z.boolean(), items: z.array(z.looseObject({ eventId: id })) });
const passBody = z.looseObject({
seriesId: id,
name: z.string().trim().min(1).max(120),
description: z.string().max(5000).nullable().optional(),
price: z.union([z.number().min(0), z.string().max(20)]),
saleEndsAt: z.string().max(40).nullable().optional(),
isActive: z.boolean().optional(),
items: z.array(z.object({ eventId: id, eventOptionId: id, variantId: id.nullable().optional() })).min(1).max(100),
});
export const listPasses = defineRoute({
method: 'get', path: '/season-passes', tag: 'Series', summary: 'Season passes',
description: 'With their events, how the price is shared between them, and how many were sold or are unpaid.',
access: 'supervisor', campusScope: SPANS, query: z.object({ seriesId: id.optional() }),
response: z.object({ data: z.array(pass.extend({ sold: z.number().int(), unpaid: z.number().int() })) }),
async handler({ query }) { return { data: plain(await series.listPasses(query.seriesId)) }; },
});
export const createPass = defineRoute({
method: 'post', path: '/season-passes', tag: 'Series', summary: 'Create a season pass',
description: 'One price for several events in a series. Campus-scoped supervisors can only include their own campuses\' events.',
access: 'supervisor', campusScope: 'checked in createPass (its events\' campuses)', status: 201, body: passBody, response: single(pass),
async handler({ body, user, req }) { return { data: plain(await series.createPass(body, actorOf(user), await ctxOf(req))) }; },
});
export const updatePass = defineRoute({
method: 'patch', path: '/season-passes/:id', tag: 'Series', summary: 'Change a season pass',
description: 'Its events and price can\'t change once somebody has bought it (switch it off and make a new one).',
access: 'supervisor', campusScope: 'checked in updatePass (its events\' campuses)', params: idParams, body: passBody.partial(), response: single(pass),
async handler({ params, body, user, req }) { return { data: plain(await series.updatePass(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const deletePass = defineRoute({
method: 'delete', path: '/season-passes/:id', tag: 'Series', summary: 'Delete a season pass',
description: 'Only one nobody has bought — otherwise switch it off.',
access: 'supervisor', campusScope: 'checked in deletePass (its events\' campuses)', params: idParams, response: deleted,
async handler({ params, user, req }) { await series.deletePass(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
});
export const buyPass = defineRoute({
method: 'post', path: '/season-passes/:id/buy', tag: 'Series', summary: 'Buy a season pass',
description: 'Registers you at every event on it, then answers with a checkout to pay (or issues the tickets when it\'s free).',
access: 'user', status: 201, params: idParams,
body: z.object({ quantity: z.number().int().min(1).max(50).default(1), provider: z.string().max(40).optional() }),
response: single(z.looseObject({})),
async handler({ params, body, user }) { return { data: plain(await series.buyPass(params.id, user!.id, body)) }; },
});
export const payPass = defineRoute({
method: 'post', path: '/season-pass-purchases/:id/checkout', tag: 'Series', summary: 'Pay what\'s owed on a pass',
access: 'user', params: idParams, body: z.object({ provider: z.string().max(40).optional() }), response: single(z.looseObject({})),
async handler({ params, body, user }) { return { data: plain(await series.payPassPurchase(params.id, user!.id, body)) }; },
});
export const myPasses = defineRoute({
method: 'get', path: '/me/season-passes', tag: 'Me', summary: 'My season passes',
access: 'user',
response: z.object({ data: z.array(z.looseObject({ id, quantity: z.number().int(), total: z.number(), status: z.string(), createdAt: isoDate })) }),
async handler({ user }) { return { data: plain(await series.myPassPurchases(user!.id)) }; },
});
// ─── Bundle deals ────────────────────────────────────────────────────────────
const basketBody = z.object({
eventId: id,
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() })).max(50),
forUserId: id.nullable().optional().describe('Staff: price it for this person (null = a walk-in without an account)'),
});
const bundle = z.looseObject({ id, eventId: id, name: z.string(), kind: z.enum(['exact', 'mix', 'free', 'group']), price: z.number().nullable(), isActive: z.boolean(), summary: z.string(), headline: z.string(), available: z.boolean() });
const bundleBody = z.looseObject({
name: z.string().trim().min(1).max(80),
kind: z.enum(['exact', 'mix', 'free', 'group']).optional().describe('exact = this set; mix = any N; free = buy N get M free; group = a price for groups'),
price: z.union([z.number(), z.string().max(20)]).nullable().optional(),
setSize: z.number().int().nullable().optional(),
freeCount: z.number().int().nullable().optional(),
groupPricing: z.enum(['per_ticket', 'percent']).nullable().optional(),
items: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).optional() })).max(50).optional(),
description: z.string().max(2000).nullable().optional(),
isActive: z.boolean().optional(),
validFrom: z.string().max(40).nullable().optional(),
validUntil: z.string().max(40).nullable().optional(),
});
export const listBundles = defineRoute({
method: 'get', path: '/events/:id/bundles', tag: 'Deals', summary: 'An event\'s bundle deals',
description: 'With how often each has been used and the discount given.',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
response: z.object({ data: z.array(bundle.extend({ uses: z.number(), registrations: z.number().int(), totalDiscount: z.number() })) }),
async handler({ params }) { return { data: plain(await deals.listBundles(params.id)) }; },
});
export const createBundle = defineRoute({
method: 'post', path: '/events/:id/bundles', tag: 'Deals', summary: 'Add a bundle deal',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [campusScope.scopeEvent({ need: 'manage' })], status: 201,
params: idParams, body: bundleBody, response: single(bundle),
async handler({ params, body, user, req }) { return { data: plain(await deals.createBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const updateBundle = defineRoute({
method: 'patch', path: '/bundles/:id', tag: 'Deals', summary: 'Change a bundle deal',
description: 'Only what\'s sent changes; when a pricing field is sent the whole deal is checked again.',
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
params: idParams, body: bundleBody.partial(), response: single(bundle),
async handler({ params, body, user, req }) { return { data: plain(await deals.updateBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const deleteBundle = defineRoute({
method: 'delete', path: '/bundles/:id', tag: 'Deals', summary: 'Remove a bundle deal',
description: 'One that\'s been used is switched off instead (`switchedOff`), so registrations that got it keep their price.',
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
params: idParams, response: single(z.object({ deleted: z.literal(true).optional(), switchedOff: z.literal(true).optional(), message: z.string() })),
async handler({ params, user, req }) { return { data: await deals.deleteBundle(params.id, actorOf(user), await ctxOf(req)) }; },
});
export const quote = defineRoute({
method: 'post', path: '/bundles/quote', tag: 'Deals', summary: 'The bundle deals a basket gets',
description: 'Before any coupon code — what the register page shows as you choose tickets.',
access: 'optional', body: basketBody,
response: single(z.object({ subtotal: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), total: z.number() })),
async handler({ body, user }) { return { data: plain(await deals.quoteBundles(body, user ? actorOf(user) : null)) }; },
});
// ─── Coupon codes ────────────────────────────────────────────────────────────
const coupon = z.looseObject({ id, code: z.string(), type: z.enum(['percent', 'fixed', 'comp']), value: z.number(), active: z.boolean(), summary: z.string() });
const couponBody = z.looseObject({
code: z.string().trim().min(3).max(32),
type: z.enum(['percent', 'fixed', 'comp']).describe('comp = free'),
value: z.union([z.number(), z.string().max(20)]).optional(),
description: z.string().max(2000).nullable().optional(),
active: z.boolean().optional(),
stackEarlyBird: z.boolean().optional(),
validFrom: z.string().max(40).nullable().optional(),
validUntil: z.string().max(40).nullable().optional(),
maxUses: z.union([z.number().int(), z.string()]).nullable().optional(),
maxUsesPerUser: z.union([z.number().int(), z.string()]).nullable().optional(),
minQuantity: z.union([z.number().int(), z.string()]).nullable().optional(),
restrictRoles: z.array(z.enum(['user', 'staff', 'supervisor', 'admin'])).optional(),
restrictDomain: z.string().max(120).nullable().optional(),
eventIds: z.array(id).max(200).optional().describe('Empty = every event (not for campus-scoped supervisors)'),
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish() })).max(200).optional(),
userIds: z.array(id).max(1000).optional(),
});
export const listCoupons = defineRoute({
method: 'get', path: '/coupons', tag: 'Deals', summary: 'Coupon codes',
description: 'With how often each has been used and the discount given. Campus-scoped supervisors see codes for their campuses\' events.',
access: 'supervisor', campusScope: 'scopeList (couponAllowed in listCoupons)',
response: z.object({ data: z.array(coupon.extend({ uses: z.number().int(), totalDiscount: z.number() })) }),
async handler({ req }) { return { data: plain(await deals.listCoupons(await ctxOf(req))) }; },
});
export const redemptions = defineRoute({
method: 'get', path: '/coupons/redemptions', tag: 'Deals', summary: 'Coupon and bundle uses',
description: 'For the Discounts report, newest first.',
access: 'supervisor', campusScope: 'scopeList (listWhere)',
query: z.object({ eventIds: z.string().max(4000).optional().describe('Comma-separated'), from: z.string().max(40).optional(), to: z.string().max(40).optional() }),
response: z.object({ data: z.array(z.looseObject({ id, kind: z.enum(['coupon', 'bundle']), uses: z.number(), discountAmount: z.number(), createdAt: isoDate })) }),
async handler({ query, req }) {
const date = (v?: string) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
const eventIds = String(query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
return { data: plain(await deals.listRedemptions({ eventIds, from: date(query.from), to: date(query.to) }, await campusScope.listWhere(req, 'event'))) };
},
});
export const getCoupon = defineRoute({
method: 'get', path: '/coupons/:id', tag: 'Deals', summary: 'One coupon code with its uses',
access: 'supervisor', campusScope: 'scopeList (couponAllowed in getCoupon)', params: idParams,
response: single(coupon.extend({ redemptions: z.array(z.looseObject({ id })) })),
async handler({ params, req }) { return { data: plain(await deals.getCoupon(params.id, await ctxOf(req))) }; },
});
export const createCoupon = defineRoute({
method: 'post', path: '/coupons', tag: 'Deals', summary: 'Create a coupon code',
access: 'supervisor', campusScope: 'checked in createCoupon (eventIdsAllowed)', status: 201, body: couponBody, response: single(coupon),
async handler({ body, user, req }) { return { data: plain(await deals.createCoupon(body, actorOf(user), await ctxOf(req))) }; },
});
export const updateCoupon = defineRoute({
method: 'patch', path: '/coupons/:id', tag: 'Deals', summary: 'Change a coupon code',
description: 'Only what\'s sent changes. Discounts already given keep their amount; a used code can\'t be renamed.',
access: 'supervisor', campusScope: 'checked in updateCoupon (couponAllowed)', params: idParams, body: couponBody.partial(), response: single(coupon),
async handler({ params, body, user, req }) { return { data: plain(await deals.updateCoupon(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const checkCode = defineRoute({
method: 'post', path: '/coupons/check', tag: 'Deals', summary: 'Check a coupon code against a basket',
description: 'Always a 200: `valid` with the discount and total, or `valid: false` with why. Restricted codes need you signed in.',
access: 'optional', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })],
body: basketBody.extend({ code: z.string().trim().min(1).max(40) }),
response: single(z.discriminatedUnion('valid', [
z.object({ valid: z.literal(true), code: z.string(), type: z.string(), value: z.number(), summary: z.string(), description: z.string().nullable(), discount: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), subtotal: z.number(), total: z.number() }),
z.object({ valid: z.literal(false), message: z.string() }),
])),
async handler({ body, user }) { return { data: plain(await deals.checkCode(body, user ? actorOf(user) : null)) }; },
});
// ─── Campuses ────────────────────────────────────────────────────────────────
const publicCampus = z.looseObject({ id, name: z.string(), slug: z.string() });
const adminCampus = publicCampus.extend({ archivedAt: isoDate.nullable(), order: z.number().int(), _count: z.looseObject({ events: z.number().int() }) });
const campusBody = z.looseObject({
name: z.string().trim().min(1).max(120),
slug: z.string().trim().max(80).optional(),
colour: z.string().max(20).nullable().optional(),
notifyRecipientIds: z.array(id).max(100).optional().describe('Who gets this campus\'s daily summary (staff only)'),
archived: z.boolean().optional(),
});
export const listCampuses = defineRoute({
method: 'get', path: '/campuses', tag: 'Campuses', summary: 'The active campuses',
description: '`enabled` false means campuses are switched off (the list is then empty).',
access: 'public', response: single(z.object({ enabled: z.boolean(), campuses: z.array(publicCampus) })),
async handler() { return { data: plain(await campuses.publicCampuses()) }; },
});
export const adminList = defineRoute({
method: 'get', path: '/campuses/admin', tag: 'Campuses', summary: 'Every campus (Site settings)',
description: 'Archived ones last, with event counts and summary recipients.',
access: 'admin', response: single(z.object({ enabled: z.boolean(), campuses: z.array(adminCampus) })),
async handler() { return { data: plain(await campuses.adminCampuses()) }; },
});
export const campusBySlug = defineRoute({
method: 'get', path: '/campuses/by-slug/:slug', tag: 'Campuses', summary: 'A campus landing page',
access: 'public', params: z.object({ slug: z.string().min(1).max(80) }), response: single(publicCampus),
async handler({ params }) { return { data: plain(await campuses.campusBySlug(params.slug)) }; },
});
export const setEnabled = defineRoute({
method: 'put', path: '/campuses/enabled', tag: 'Campuses', summary: 'Switch campuses on or off',
description: 'Off hides campus fields and filters; nothing is deleted.',
access: 'admin', body: z.object({ enabled: z.boolean() }), response: single(z.object({ enabled: z.boolean() })),
async handler({ body, user, req }) { return { data: await campuses.setCampusesEnabled(body.enabled, actorOf(user), await ctxOf(req)) }; },
});
export const reorder = defineRoute({
method: 'put', path: '/campuses/order', tag: 'Campuses', summary: 'Put the campuses in order',
access: 'admin', body: z.object({ ids: z.array(id).min(1).max(200) }), response: single(z.object({ ids: z.array(id) })),
async handler({ body }) { await campuses.reorderCampuses(body.ids); return { data: { ids: body.ids } }; },
});
export const createCampus = defineRoute({
method: 'post', path: '/campuses', tag: 'Campuses', summary: 'Add a campus',
access: 'admin', status: 201, body: campusBody, response: single(adminCampus),
async handler({ body, user, req }) { return { data: plain(await campuses.createCampus(body, actorOf(user), await ctxOf(req))) }; },
});
export const updateCampus = defineRoute({
method: 'patch', path: '/campuses/:id', tag: 'Campuses', summary: 'Change a campus',
description: 'Details, summary recipients, or `archived`.',
access: 'admin', params: idParams, body: campusBody.partial(), response: single(adminCampus),
async handler({ params, body, user, req }) { return { data: plain(await campuses.updateCampus(params.id, body, actorOf(user), await ctxOf(req))) }; },
});
export const deleteCampus = defineRoute({
method: 'delete', path: '/campuses/:id', tag: 'Campuses', summary: 'Delete a campus',
description: 'Only one with no events and no staff limited to it — otherwise archive it (CONFLICT, `reason` CAMPUS_HAS_EVENTS or CAMPUS_HAS_SCOPED_STAFF).',
access: 'admin', params: idParams, response: deleted,
async handler({ params, user, req }) { await campuses.deleteCampus(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
});
export const audience = defineRoute({
method: 'get', path: '/campuses/:id/audience', tag: 'Campuses', summary: 'Everyone at a campus',
description: 'For the email and WhatsApp composers: people whose home campus it is, plus (`registrants=true`) people registered for its events in the last 12 months. News consent is checked when sending.',
access: 'supervisor', campusScope: 'scopeList (own campuses only, in campusAudience)', params: idParams,
query: z.object({ registrants: z.enum(['true', 'false']).default('false').transform(v => v === 'true') }),
response: single(z.object({ campus: z.object({ id, name: z.string() }), users: z.array(z.object({ id, name: z.string(), email: z.string(), phoneNumber: z.string().nullable() })) })),
async handler({ params, query, req }) { return { data: plain(await campuses.campusAudience(params.id, query.registrants, await ctxOf(req))) }; },
});
+229
View File
@@ -0,0 +1,229 @@
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import * as setup from '../../services/setup';
import { prisma } from '../legacy';
import auth from '../../middleware/authMiddleware';
/* eslint-disable @typescript-eslint/no-explicit-any -- multer and the scope middleware are JS */
const campusScope = require('../../utils/campusScope') as {
listWhere(req: unknown, path?: string): Promise<object>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
};
const uploads = require('../../controllers/uploadController') as { upload: any; uploadLogo: any; uploadFavicon: any };
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
// ─── Sections ────────────────────────────────────────────────────────────────
const section = z.looseObject({
id, eventId: id, name: z.string(),
allowedOptions: z.array(z.looseObject({ eventOptionId: id, eventOption: z.looseObject({ id, name: z.string() }) })),
});
export const listSections = defineRoute({
method: 'get',
path: '/sections',
tag: 'Events (staff)',
summary: 'Scanning sections',
description: 'Which ticket types may come in at which door, for one event or every event you can see.',
access: 'staff',
campusScope: 'scopeList (listWhere)',
query: z.object({ eventId: id.optional() }),
response: z.object({ data: z.array(section.extend({ event: z.object({ id, title: z.string() }) })) }),
async handler({ query, req }) {
return { data: plain(await setup.listSections(query.eventId, await campusScope.listWhere(req, 'event'))) };
},
});
export const createSection = defineRoute({
method: 'post',
path: '/sections',
tag: 'Events (staff)',
summary: 'Add a scanning section',
access: 'supervisor',
campusScope: 'scopeEvent(body.eventId, manage)',
middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId, need: 'manage' })],
status: 201,
body: z.object({ eventId: id, name: z.string().trim().min(1).max(120), allowedOptionIds: z.array(id).max(100).optional() }),
response: single(section),
async handler({ body }) {
return { data: plain(await setup.createSection(body)) };
},
});
export const updateSection = defineRoute({
method: 'patch',
path: '/sections/:id',
tag: 'Events (staff)',
summary: 'Change a scanning section',
description: '`allowedOptionIds`, when sent, replaces its ticket types.',
access: 'supervisor',
campusScope: 'scopeEvent(section, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'section', need: 'manage' })],
params: idParams,
body: z.object({ name: z.string().trim().min(1).max(120).optional(), allowedOptionIds: z.array(id).max(100).optional() }),
response: single(section),
async handler({ params, body }) {
return { data: plain(await setup.updateSection(params.id, body)) };
},
});
export const deleteSection = defineRoute({
method: 'delete',
path: '/sections/:id',
tag: 'Events (staff)',
summary: 'Remove a scanning section',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await setup.deleteSection(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Planned costs ───────────────────────────────────────────────────────────
const cost = z.object({
id, eventId: id, label: z.string(), costType: z.enum(['once_off', 'per_item']), amount: z.number(),
eventOptionId: id.nullable(), eventOption: z.object({ id, name: z.string() }).nullable(),
paidFromMethod: z.string().nullable().describe('How it was paid out of the takings (cash, card, eft, other)'),
notes: z.string().nullable(), createdAt: isoDate, updatedAt: isoDate.optional(),
});
const costBody = z.object({
label: z.string().trim().min(1).max(200),
costType: z.enum(['once_off', 'per_item']).describe('per_item = per ticket sold of `eventOptionId`'),
amount: z.union([z.number().min(0), z.string().max(20)]),
eventOptionId: id.nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
paidFromMethod: z.enum(['cash', 'card', 'eft', 'other']).nullable().optional(),
});
export const listCosts = defineRoute({
method: 'get',
path: '/events/:id/costs',
tag: 'Events (staff)',
summary: 'An event\'s planned costs',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
response: z.object({ data: z.array(cost) }),
async handler({ params }) {
return { data: plain(await setup.listCosts(params.id)) };
},
});
export const createCost = defineRoute({
method: 'post',
path: '/events/:id/costs',
tag: 'Events (staff)',
summary: 'Add a planned cost',
description: 'Admins. Not on a cashed-up event.',
access: 'admin',
status: 201,
params: idParams,
body: costBody,
response: single(cost),
async handler({ params, body }) {
return { data: plain(await setup.createCost(params.id, body)) };
},
});
export const updateCost = defineRoute({
method: 'patch',
path: '/costs/:id',
tag: 'Events (staff)',
summary: 'Change a planned cost',
access: 'admin',
params: idParams,
body: costBody.partial(),
response: single(cost),
async handler({ params, body }) {
return { data: plain(await setup.updateCost(params.id, body)) };
},
});
export const deleteCost = defineRoute({
method: 'delete',
path: '/costs/:id',
tag: 'Events (staff)',
summary: 'Remove a planned cost',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await setup.deleteCost(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Image uploads ───────────────────────────────────────────────────────────
/** Runs a multer step for field "image", keeping any error on the request. */
const receive = (m: any): RequestHandler => (req, res, next) => m.single('image')(req, res, (e: Error | undefined) => {
if (e) (req as Request & { multerError?: Error }).multerError = e;
next();
});
/** Branding images: admins — or anyone during first-time setup, before any account exists. */
const brandingAccess: RequestHandler = async (req, res, next) => {
try {
if (await prisma.user.count() === 0) return next();
} catch { /* fall through to the admin check */ }
auth.protect(req, res, (e?: unknown) => (e ? next(e) : auth.admin(req, res, next)));
};
const uploaded = (folder: string) => async ({ req }: { req: Request }) => {
const r = req as Request & { multerError?: Error; file?: { filename: string } };
if (r.multerError) throw new ApiError('VALIDATION_FAILED', `Upload failed: ${r.multerError.message}`, { image: r.multerError.message });
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { image: 'Choose an image' });
const path = `/uploads/${folder}/${r.file.filename}`;
return { data: { path, url: absoluteUrl(req, path)! } };
};
const uploadResult = single(z.object({ path: z.string().describe('What to save on the event or setting'), url: z.string().describe('The same, as a full address') }));
const IMAGE = 'A JPEG, PNG, GIF or WebP image';
const LOGO = 'A JPEG, PNG, WebP or SVG image';
export const uploadEventImage = defineRoute({
method: 'post',
path: '/uploads/event-image',
tag: 'Uploads',
summary: 'Upload an event picture',
description: 'Returns the path to save as the event\'s `picture`.',
access: 'supervisor',
campusScope: 'campusIndependent (only stores the file; the event is checked when it is saved)',
middleware: [receive(uploads.upload)],
upload: { field: 'image', description: IMAGE },
response: uploadResult,
handler: uploaded('events'),
});
export const uploadLogo = defineRoute({
method: 'post',
path: '/uploads/logo',
tag: 'Uploads',
summary: 'Upload the site logo',
description: 'Admins — or anyone during first-time setup, before any account exists.',
access: 'public',
middleware: [brandingAccess, receive(uploads.uploadLogo)],
upload: { field: 'image', description: LOGO },
response: uploadResult,
handler: uploaded('branding'),
});
export const uploadFavicon = defineRoute({
method: 'post',
path: '/uploads/favicon',
tag: 'Uploads',
summary: 'Upload the site icon (favicon)',
description: 'Admins — or anyone during first-time setup, before any account exists.',
access: 'public',
middleware: [brandingAccess, receive(uploads.uploadFavicon)],
upload: { field: 'image', description: 'An ICO, PNG or SVG image' },
response: uploadResult,
handler: uploaded('branding'),
});
+124
View File
@@ -0,0 +1,124 @@
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[];
/** A multipart upload (the file in `field`, saved by a multer step in `middleware`). */
upload?: { field: string; description: string };
/**
* The answer is a file, not JSON: the handler returns `{ data: { contentType, filename, body } }`
* and `response` is `fileResponse`. `contentType` here is what the docs show.
*/
file?: { contentType: string };
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.');
}
if (def.file) {
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
res.setHeader('Content-Type', f.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
res.status(def.status ?? 200).send(f.body);
return;
}
res.status(def.status ?? 200).json(checked.data);
} catch (err) {
next(err);
}
};
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
}
}
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
+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 });
+40
View File
@@ -0,0 +1,40 @@
jest.mock('../src/config/db', () => ({}));
const { auditLogWhere } = require('../src/utils/adminAudit');
describe('auditLogWhere', () => {
test('no filters → everything', () => {
expect(auditLogWhere({})).toEqual({});
});
test('a "to" date includes that whole day', () => {
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
expect(where.createdAt.lte).toBeUndefined();
});
test('a full timestamp is used as given', () => {
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
});
test('action and role filter exactly', () => {
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
});
test('search looks at the person, the target and the IP', () => {
const where = auditLogWhere({ search: ' #ab12 ' });
expect(where.OR).toEqual([
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
{ targetId: { startsWith: 'ab12' } },
{ ip: { startsWith: '#ab12' } },
]);
});
test('a blank search is ignored', () => {
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
});
});
+95
View File
@@ -0,0 +1,95 @@
const mockDb = {
registration: { findMany: jest.fn(), findUnique: jest.fn() },
eventOption: { findMany: jest.fn() },
attendee: { findMany: jest.fn(), updateMany: jest.fn() },
registrationOption: { findMany: jest.fn(), update: jest.fn() },
registrationOptionTranche: { deleteMany: jest.fn(), create: jest.fn() },
user: { findUnique: jest.fn().mockResolvedValue({ role: "user" }) },
$transaction: jest.fn(fn => fn(mockDb)),
};
const mockLeft = jest.fn().mockResolvedValue(null);
jest.mock("../src/utils/stock", () => ({ remainingFor: (...a) => mockLeft(...a) }));
jest.mock("../src/utils/pricing", () => ({ resolveOptionPrice: jest.fn().mockResolvedValue({ price: 150, tierId: null }) }));
const mockStatus = jest.fn();
jest.mock("../src/payments/webhook", () => ({ updateRegistrationStatus: (...a) => mockStatus(...a) }));
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn() }));
const mockLicence = jest.fn().mockResolvedValue({ readOnly: false });
jest.mock('../src/hub/licence', () => ({ licenceStatus: (...a) => mockLicence(...a) }));
const mockCancel = jest.fn();
const mockUpdate = jest.fn();
jest.mock('../src/controllers/registrationController', () => ({
cancelRegistration: (req, res) => mockCancel(req, res),
updateRegistrationOptions: (req, res) => mockUpdate(req, res),
}));
const mockAssign = jest.fn();
jest.mock('../src/controllers/allocationController', () => ({ assign: (req, res) => mockAssign(req, res) }));
const mockEmail = jest.fn();
jest.mock('../src/controllers/eventController', () => ({ emailEventAttendees: (req, res) => mockEmail(req, res), whatsappEventAttendees: jest.fn() }));
const bulk = require('../src/utils/bulkRegistrations');
const { isBlocked } = require('../src/hub/readOnly');
const req = { user: { id: 'sup', role: 'supervisor' } };
const reg = (id, extra = {}) => ({ id, userId: `u-${id}`, eventId: 'ev1', status: 'pending', seasonPassPurchaseId: null, registrationOptions: [{ eventOptionId: 'adult', variantId: null, quantity: 1, sessionId: null }], ...extra });
beforeEach(() => jest.clearAllMocks());
test('the input is checked before anything runs', async () => {
await expect(bulk.runBulk(req, { action: 'explode', registrationIds: ['a'] })).rejects.toThrow('Unknown');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: [] })).rejects.toThrow('Tick at least one');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: Array.from({ length: 501 }, (_, i) => `r${i}`) })).rejects.toThrow('At most 500');
});
test('cancelling runs the normal cancel for each; already-cancelled ones are skipped; a refusal is reported', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { status: 'cancelled' }), reg('c')]);
mockCancel.mockImplementation((r, res) => (r.params.id === 'c' ? res.status(403).json({ message: 'has payments' }) : res.json({ message: 'Registration cancelled' })));
const out = await bulk.runBulk(req, { action: 'cancel', registrationIds: ['a', 'b', 'c', 'gone'] });
expect(out).toMatchObject({ done: 1, skipped: 1, failed: 2 });
expect(out.results.find(r => r.id === 'c')).toMatchObject({ ok: false, message: 'has payments' });
expect(out.results.find(r => r.id === 'gone')).toMatchObject({ ok: false, message: 'Not found' });
expect(mockCancel).toHaveBeenCalledTimes(2);
expect(mockCancel.mock.calls[0][0].user).toBe(req.user);
});
test('moving keeps the same lines and tickets, repriced; checks places; not other events or season passes', async () => {
mockDb.eventOption.findMany.mockResolvedValue([
{ id: 'adult', eventId: 'ev1', name: 'Adult', event: { id: 'ev1', cashupStatus: 'open' } },
{ id: 'vip', eventId: 'ev1', name: 'VIP', event: { id: 'ev1', cashupStatus: 'open' } },
]);
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { eventId: 'ev2' }), reg('c', { seasonPassPurchaseId: 'p1' }), reg('d')]);
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'line1', quantity: 1 }]);
mockLeft.mockResolvedValueOnce(null).mockResolvedValueOnce(0);
mockStatus.mockResolvedValue({ totalDue: 150, totalPaid: 100 });
const out = await bulk.runBulk(req, { action: 'moveOption', registrationIds: ['a', 'b', 'c', 'd'], fromOptionId: 'adult', toOptionId: 'vip' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: true, message: 'Moved — R50.00 now owing' });
expect(mockDb.registrationOption.update).toHaveBeenCalledWith({ where: { id: 'line1' }, data: { eventOptionId: 'vip', variantId: null, priceSnapshot: 150, appliedTierId: null } });
expect(mockDb.attendee.updateMany).toHaveBeenCalledWith({ where: { registrationOptionId: 'line1' }, data: { eventOptionId: 'vip', variantId: null } });
expect(out.results.find(r => r.id === 'b').message).toBe('Not for this event');
expect(out.results.find(r => r.id === 'c').message).toMatch(/season pass/);
expect(out.results.find(r => r.id === 'd')).toMatchObject({ ok: false, message: 'Only 0 places left on VIP' });
});
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
mockLicence.mockResolvedValueOnce({ readOnly: true });
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
});
test('allocating places each named person; a registration without names says so', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b')]);
mockDb.attendee.findMany.mockImplementation(({ where }) => Promise.resolve(where.registrationId === 'a' ? [{ id: 'p1', name: 'Amy' }, { id: 'p2', name: 'Ben' }] : []));
mockAssign.mockImplementation((r, res) => (r.body.attendeeId === 'p2' ? res.status(400).json({ message: 'Cabin 3 is full' }) : res.json({})));
const out = await bulk.runBulk(req, { action: 'allocate', registrationIds: ['a', 'b'], setId: 's1', groupId: 'g1' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: false, message: 'Ben: Cabin 3 is full' });
expect(out.results.find(r => r.id === 'b').message).toMatch(/No named people/);
});
test('messages go per event as an event update to exactly the ticked registrations', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b'), reg('c', { eventId: 'ev2' })]);
mockEmail.mockImplementation((r, res) => res.json({ sent: r.body.filter.registrationIds.length }));
const out = await bulk.runBulk(req, { action: 'message', registrationIds: ['a', 'b', 'c'], channel: 'email', subject: 'Bus times', text: 'The bus leaves at 7:30.' });
expect(out.done).toBe(3);
expect(mockEmail.mock.calls.map(c => [c[0].params.id, c[0].body.filter.registrationIds, c[0].body.kind])).toEqual([['ev1', ['a', 'b'], 'update'], ['ev2', ['c'], 'update']]);
await expect(bulk.runBulk(req, { action: 'message', registrationIds: ['a'], channel: 'email', text: 'x' })).rejects.toThrow('subject');
});
+86
View File
@@ -0,0 +1,86 @@
const count = jest.fn().mockResolvedValue(2);
const mockDb = {
registration: { count, findMany: jest.fn().mockResolvedValue([]) },
consentRequest: { findMany: jest.fn().mockResolvedValue([]) },
waitlistEntry: { count },
volunteerSwap: { count },
servingRole: { findMany: jest.fn().mockResolvedValue([{ needed: 3, _count: { slots: 1 } }, { needed: 2, _count: { slots: 2 } }]) },
event: { count, findMany: jest.fn().mockResolvedValue([]) },
providerTransaction: { count },
dataRequest: { count },
webhookDelivery: { count },
payment: { findMany: jest.fn().mockResolvedValue([]) },
user: { findMany: jest.fn().mockResolvedValue([]) },
};
jest.mock('../src/config/db', () => mockDb);
const mockScope = jest.fn();
jest.mock('../src/utils/campusScope', () => ({
scopeFor: (...a) => mockScope(...a),
eventWhere: scope => (scope ? { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] } : {}),
}));
const dashboard = require('../src/utils/dashboard');
beforeEach(() => jest.clearAllMocks());
test('an amount typed in the palette', () => {
expect(dashboard.amountOf('R450')).toBe(450);
expect(dashboard.amountOf('r 450,50')).toBe(450.5);
expect(dashboard.amountOf('450.5')).toBe(450.5);
expect(dashboard.amountOf('Camp 2026')).toBeNull();
expect(dashboard.amountOf('')).toBeNull();
});
describe('what is waiting', () => {
test('admins see everything they can act on', async () => {
const items = await dashboard.pendingItems({ user: { role: 'admin' } }, null);
expect(items.map(i => i.key)).toEqual(['privacy', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks']);
// Only roles still short of people count.
expect(items.find(i => i.key === 'roster').count).toBe(1);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/admin/registrations?status=pending');
});
test('a campus-limited supervisor: no cashups, provider payments, privacy or webhooks; only their events', async () => {
const scope = { campusIds: ['north'] };
const items = await dashboard.pendingItems({ user: { role: 'supervisor' } }, scope);
expect(items.map(i => i.key)).toEqual(['unpaid', 'cover', 'roster', 'waitlist']);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/supervisor/reports?report=regStatus');
const where = mockDb.registration.count.mock.calls[0][0].where;
expect(where.event.AND[0]).toEqual({ OR: [{ campusId: { in: ['north'] } }, { campusId: null }] });
});
test('items with nothing waiting are left out', async () => {
count.mockResolvedValue(0);
mockDb.servingRole.findMany.mockResolvedValueOnce([]);
expect(await dashboard.pendingItems({ user: { role: 'admin' } }, null)).toEqual([]);
count.mockResolvedValue(2);
});
});
describe('palette search', () => {
test('too short a query finds nothing', async () => {
expect(await dashboard.search({ user: { role: 'admin' } }, 'a')).toEqual([]);
});
test('staff get events only, linked to the public page', async () => {
mockScope.mockResolvedValue(null);
mockDb.event.findMany.mockResolvedValueOnce([{ id: 'e1', title: 'Camp 2026', startDate: new Date('2026-03-01'), isActive: true, campus: null }]);
const out = await dashboard.search({ user: { role: 'staff' } }, 'camp');
expect(out).toEqual([expect.objectContaining({ type: 'event', href: '/events/e1' })]);
expect(mockDb.user.findMany).not.toHaveBeenCalled();
expect(mockDb.payment.findMany).not.toHaveBeenCalled();
});
test('supervisors find payments by amount; admins also people', async () => {
mockScope.mockResolvedValue(null);
mockDb.payment.findMany.mockResolvedValueOnce([{ id: 'p1', amount: 450, method: 'card', createdAt: new Date(), registrationId: 'r1', user: { name: 'Thandi' }, event: { title: 'Camp' } }]);
const sup = await dashboard.search({ user: { role: 'supervisor' } }, 'R450');
expect(sup.find(r => r.type === 'payment')).toMatchObject({ title: 'R450.00 — Thandi', href: '/dashboard/supervisor/payments' });
expect(mockDb.payment.findMany.mock.calls[0][0].where.amount).toEqual({ in: [450, -450] });
expect(mockDb.user.findMany).not.toHaveBeenCalled();
mockDb.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Thandi Test', email: 't@test.local', role: 'user' }]);
const adm = await dashboard.search({ user: { role: 'admin' } }, 'thandi');
expect(adm.find(r => r.type === 'person')).toMatchObject({ href: '/dashboard/admin/users?q=t%40test.local' });
});
});
+41
View File
@@ -0,0 +1,41 @@
const mockDb = {
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
$transaction: jest.fn(fn => fn(mockDb)),
};
jest.mock('../src/config/db', () => mockDb);
const filters = require('../src/utils/savedFilters');
beforeEach(() => jest.clearAllMocks());
test('only simple filter values are kept', () => {
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
});
test('pages and names are checked', async () => {
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
expect(() => filters.cleanName(' ')).toThrow('name');
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
});
test('saving a default clears the old default; saving the same name replaces it', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
});
test('at most 30 per page', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue(null);
mockDb.savedFilter.count.mockResolvedValue(30);
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
});
test('someone else\'s saved filter is "not found"', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
});
+659
View File
@@ -0,0 +1,659 @@
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: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as a supervisor')(req, res, next as (e: Error) => void)),
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, blockEvent: false };
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),
// Route-level checks: refuse like v1 does (v1 shape — the v2 wrapper isn't in this test app).
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
listWhere: async () => ({}),
}));
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
const mockDetails = {
getEventDetail: jest.fn(), eventCalendar: jest.fn(), eventByAlias: jest.fn(), createOption: jest.fn(), updateOption: jest.fn(), deleteOption: jest.fn(),
createVariant: jest.fn(), updateVariant: jest.fn(), deleteVariant: jest.fn(), listAttachments: jest.fn(), recordAttachment: jest.fn(), deleteAttachment: jest.fn(),
attachmentsStatus: jest.fn(), syncAttachmentManifests: jest.fn(),
};
jest.mock('../src/services/eventDetails', () => Object.fromEntries(Object.keys(mockDetails).map(k => [k, (...a: unknown[]) => (mockDetails as Record<string, jest.Mock>)[k](...a)])));
// The upload step: pretend multer saved the file (or refused it).
const mockUpload = { file: null as object | null, error: null as Error | null };
jest.mock('../src/controllers/eventController', () => ({
receiveAttachment: (req: { file?: object; multerError?: Error }, _res: unknown, next: () => void) => { if (mockUpload.error) req.multerError = mockUpload.error; else if (mockUpload.file) req.file = mockUpload.file; next(); },
}));
const mockSetup = { listSections: jest.fn(), createSection: jest.fn(), updateSection: jest.fn(), deleteSection: jest.fn(), listCosts: jest.fn(), createCost: jest.fn(), updateCost: jest.fn(), deleteCost: jest.fn() };
jest.mock('../src/services/setup', () => Object.fromEntries(Object.keys(mockSetup).map(k => [k, (...a: unknown[]) => (mockSetup as Record<string, jest.Mock>)[k](...a)])));
// Image uploads: a stand-in for multer that "saves" mockImage.
const mockImage = { file: null as object | null };
jest.mock('../src/controllers/uploadController', () => {
const m = { single: () => (req: { file?: object }, _res: unknown, cb: () => void) => { if (mockImage.file) req.file = mockImage.file; cb(); } };
return { upload: m, uploadLogo: m, uploadFavicon: m };
});
const mockSeries = { listSeries: jest.fn(), createSeries: jest.fn(), updateSeries: jest.fn(), deleteSeries: jest.fn(), seriesPage: jest.fn(), compareSeries: jest.fn(), setFollowing: jest.fn(), listPasses: jest.fn(), createPass: jest.fn(), updatePass: jest.fn(), deletePass: jest.fn(), buyPass: jest.fn(), payPassPurchase: jest.fn(), myPassPurchases: jest.fn() };
jest.mock('../src/services/series', () => Object.fromEntries(Object.keys(mockSeries).map(k => [k, (...a: unknown[]) => (mockSeries as Record<string, jest.Mock>)[k](...a)])));
const mockDeals = { listBundles: jest.fn(), createBundle: jest.fn(), updateBundle: jest.fn(), deleteBundle: jest.fn(), quoteBundles: jest.fn(), checkCode: jest.fn(), listCoupons: jest.fn(), getCoupon: jest.fn(), createCoupon: jest.fn(), updateCoupon: jest.fn(), listRedemptions: jest.fn(), publicBundlesForEvent: jest.fn() };
jest.mock('../src/services/deals', () => Object.fromEntries(Object.keys(mockDeals).map(k => [k, (...a: unknown[]) => (mockDeals as Record<string, jest.Mock>)[k](...a)])));
const mockCampuses = { publicCampuses: jest.fn(), campusBySlug: jest.fn(), adminCampuses: jest.fn(), setCampusesEnabled: jest.fn(), createCampus: jest.fn(), updateCampus: jest.fn(), reorderCampuses: jest.fn(), deleteCampus: jest.fn(), campusAudience: jest.fn() };
jest.mock('../src/services/campuses', () => Object.fromEntries(Object.keys(mockCampuses).map(k => [k, (...a: unknown[]) => (mockCampuses as Record<string, jest.Mock>)[k](...a)])));
const mockAdmin = { getRegistrations: jest.fn(), getUserRegistrations: jest.fn(), getRegistrationById: jest.fn(), updateRegistrationStatus: jest.fn(), getRegistrationsByEvent: jest.fn(), createManualRegistration: jest.fn(), updateRegistrationOptions: jest.fn(), submitFormResponses: jest.fn(), replaceFormResponses: jest.fn(), getFormDraft: jest.fn(), saveFormDraft: jest.fn(), sendInvoice: jest.fn(), applyCoupon: jest.fn(), removeCoupon: jest.fn(), updateAttendees: jest.fn(), sendAttendeeFormLink: jest.fn() };
jest.mock('../src/services/registrationAdmin', () => Object.fromEntries(Object.keys(mockAdmin).map(k => [k, (...a: unknown[]) => (mockAdmin as Record<string, jest.Mock>)[k](...a)])));
jest.mock('../src/utils/seasonPass', () => ({ notOnSeasonPass: (_req: unknown, _res: unknown, next: () => void) => next() }));
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/full');
expect(successorOf('PUT', '/api/events/options/o1')).toBe('/api/v2/ticket-options/o1');
expect(successorOf('DELETE', '/api/events/e1/attachments/a1')).toBe('/api/v2/events/e1/attachments/a1');
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
expect(successorOf('GET', '/api/v2/events')).toBeNull();
});
});
describe('shared middleware on v2', () => {
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
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' } });
});
});
describe('events (staff)', () => {
const stored = (over: Record<string, unknown> = {}) => ({
id: 'e1', title: 'Camp', description: null, startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
isActive: true, isHidden: true, requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open',
campusId: null, seriesId: null, picture: '/uploads/events/c.jpg', waitlistEnabled: true,
eventOptions: [{ id: 'o1', name: 'Main Ticket', price: 600, isMainTicket: true, stockLimit: 0, earlyBirdTiers: [] }], sessions: [], ...over,
});
afterEach(() => { mockScope.blockEvent = false; });
test('/events/manage lists hidden events for staff, with the rest of each record passed through', async () => {
signedIn = { id: 's1', role: 'staff' };
mockEvents.listManagedEvents.mockResolvedValue([stored()]);
const res = await request(app).get('/api/v2/events/manage?past=true');
expect(res.status).toBe(200);
expect(res.body.data[0]).toMatchObject({ id: 'e1', isHidden: true, waitlistEnabled: true, startDate: '2026-12-04T16:00:00.000Z' });
expect(res.body.data[0].picture).toMatch(/^http:\/\/.*\/uploads\/events\/c\.jpg$/);
expect(mockEvents.listManagedEvents).toHaveBeenCalledWith({ includePast: true, includeInactive: false, excludeClosed: false, campus: undefined }, expect.objectContaining({ scope: null }));
});
test('members can\'t list or create', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/events/manage')).body.error.code).toBe('FORBIDDEN');
signedIn = { id: 's1', role: 'staff' };
expect((await request(app).post('/api/v2/events').send({ title: 'X', startDate: '2026-12-01', endDate: '2026-12-02' })).body.error.code).toBe('FORBIDDEN');
});
test('create needs a title and dates; update passes only what was sent', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
const bad = await request(app).post('/api/v2/events').send({ title: '' });
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['endDate', 'startDate', 'title']);
mockEvents.createEvent.mockResolvedValue(stored());
const made = await request(app).post('/api/v2/events').send({ title: 'Camp', startDate: '2026-12-04T16:00', endDate: '2026-12-06T12:00', waitlistEnabled: true });
expect(made.status).toBe(201);
expect(mockEvents.createEvent.mock.calls[0][0]).toMatchObject({ title: 'Camp', waitlistEnabled: true });
mockEvents.updateEvent.mockResolvedValue({ ...stored({ title: 'New' }), sessionChanges: null });
const upd = await request(app).patch('/api/v2/events/e1').send({ title: 'New' });
expect(upd.body.data).toMatchObject({ title: 'New', sessionChanges: null });
expect(mockEvents.updateEvent).toHaveBeenCalledWith('e1', { title: 'New' }, { id: 'v1', role: 'supervisor' }, expect.any(Object));
});
test("a campus-scoped supervisor can't change another campus's event", async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockScope.blockEvent = true;
expect((await request(app).patch('/api/v2/events/e1').send({ title: 'X' })).status).toBe(404);
expect(mockEvents.updateEvent).not.toHaveBeenCalled();
});
test('deactivating is for admins', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).delete('/api/v2/events/e1')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
mockEvents.deactivateEvent.mockResolvedValue({ id: 'e1', isActive: false });
expect((await request(app).delete('/api/v2/events/e1')).body.data).toEqual({ id: 'e1', isActive: false });
});
});
describe('one event in full, options, attachments', () => {
const detail = {
id: 'e1', title: 'Camp', description: null, startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z', isActive: true, isHidden: false,
requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open', campusId: null, seriesId: null, picture: null,
eventOptions: [], sessions: [], form: null, attachments: [], bundles: [],
};
afterEach(() => { mockUpload.file = null; mockUpload.error = null; });
test('/events/{id}/full asks for the staff view only for staff', async () => {
mockDetails.getEventDetail.mockResolvedValue(detail);
await request(app).get('/api/v2/events/e1/full');
expect(mockDetails.getEventDetail.mock.calls[0][1]).toEqual({ staff: false, scope: null });
signedIn = { id: 's1', role: 'staff' };
const res = await request(app).get('/api/v2/events/e1/full');
expect(res.status).toBe(200);
expect(mockDetails.getEventDetail.mock.calls[1][1]).toEqual({ staff: true, scope: null });
});
test('the calendar file is a download, not JSON', async () => {
mockDetails.eventCalendar.mockResolvedValue({ filename: 'Camp.ics', ics: 'BEGIN:VCALENDAR\nEND:VCALENDAR' });
const res = await request(app).get('/api/v2/events/e1/calendar.ics');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/calendar');
expect(res.headers['content-disposition']).toBe('attachment; filename="Camp.ics"');
expect(res.text).toContain('BEGIN:VCALENDAR');
});
test('ticket options: supervisors add and change them, only admins remove them', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockDetails.createOption.mockResolvedValue({ id: 'o2', eventId: 'e1', name: 'Child', price: 300, isMainTicket: false, stockLimit: 0, staffPrice: null, sessionMode: 'all' });
const made = await request(app).post('/api/v2/events/e1/ticket-options').send({ name: 'Child', price: 300 });
expect(made.status).toBe(201);
expect((await request(app).post('/api/v2/events/e1/ticket-options').send({ price: 300 })).body.error.fields).toHaveProperty('name');
expect((await request(app).delete('/api/v2/ticket-options/o2')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
mockDetails.deleteOption.mockRejectedValueOnce(new ServiceError(400, 'Cannot delete event option that has registrations'));
expect((await request(app).delete('/api/v2/ticket-options/o2')).body.error.message).toBe('Cannot delete event option that has registrations');
});
test('uploading an attachment: the file, or why it was refused', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockUpload.error = new Error('Unsupported file type');
const refused = await request(app).post('/api/v2/events/e1/attachments');
expect(refused.body.error).toMatchObject({ code: 'VALIDATION_FAILED', fields: { file: 'Unsupported file type' } });
mockUpload.error = null;
mockUpload.file = { originalname: 'packing.pdf', filename: 'event-file-1.pdf', mimetype: 'application/pdf', size: 1200 };
mockDetails.recordAttachment.mockResolvedValue({ id: 'a1', eventId: 'e1', originalName: 'packing.pdf', filename: 'event-file-1.pdf', mimeType: 'application/pdf', size: 1200, url: 'http://x/uploads/event-files/event-file-1.pdf', createdAt: new Date('2026-10-29T10:00:00Z') });
const ok = await request(app).post('/api/v2/events/e1/attachments');
expect(ok.status).toBe(201);
expect(ok.body.data).toMatchObject({ originalName: 'packing.pdf', createdAt: '2026-10-29T10:00:00.000Z' });
});
test('the upload is described as multipart and the calendar as a file', () => {
const doc = buildOpenApi({ version: 'test' }) as { paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> }; responses: Record<string, { content: Record<string, unknown> }> }>> };
expect(Object.keys(doc.paths['/events/{id}/attachments'].post.requestBody!.content)).toEqual(['multipart/form-data']);
expect(Object.keys(doc.paths['/events/{id}/calendar.ics'].get.responses['200'].content)).toEqual(['text/calendar']);
});
});
describe('sections, costs, image uploads', () => {
afterEach(() => { mockImage.file = null; });
test('costs: supervisors read them, admins add them; per-item needs a ticket type', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockSetup.listCosts.mockResolvedValue([{ id: 'c1', eventId: 'e1', label: 'Hall', costType: 'once_off', amount: 1500, eventOptionId: null, eventOption: null, paidFromMethod: null, notes: null, createdAt: new Date('2026-10-01T10:00:00Z') }]);
expect((await request(app).get('/api/v2/events/e1/costs')).body.data[0]).toMatchObject({ label: 'Hall', createdAt: '2026-10-01T10:00:00.000Z' });
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'once_off', amount: 1500 })).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'monthly', amount: 1 })).body.error.fields).toHaveProperty('costType');
mockSetup.createCost.mockRejectedValueOnce(new ServiceError(400, 'eventOptionId is required for per-item costs'));
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Shirt', costType: 'per_item', amount: 80 })).body.error.message).toBe('eventOptionId is required for per-item costs');
});
test('sections need staff to list and a supervisor to add', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/sections')).status).toBe(403);
signedIn = { id: 's1', role: 'staff' };
mockSetup.listSections.mockResolvedValue([]);
expect((await request(app).get('/api/v2/sections?eventId=e1')).body).toEqual({ data: [] });
expect(mockSetup.listSections).toHaveBeenCalledWith('e1', expect.anything());
});
test('an event picture comes back as a path to save and a full address', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).post('/api/v2/uploads/event-image')).body.error.fields).toEqual({ image: 'Choose an image' });
mockImage.file = { filename: 'event-123.jpg' };
const res = await request(app).post('/api/v2/uploads/event-image');
expect(res.body.data.path).toBe('/uploads/events/event-123.jpg');
expect(res.body.data.url).toMatch(/^http:\/\/.*\/uploads\/events\/event-123\.jpg$/);
});
test('branding images are for admins once accounts exist', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockImage.file = { filename: 'logo.png' };
expect((await request(app).post('/api/v2/uploads/logo')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
expect((await request(app).post('/api/v2/uploads/logo')).body.data.path).toBe('/uploads/branding/logo.png');
});
});
describe('series, deals and campuses', () => {
test('following a series needs an account; the page works without one', async () => {
expect((await request(app).put('/api/v2/series/s1/follow')).status).toBe(401);
signedIn = { id: 'u1', role: 'user' };
mockSeries.setFollowing.mockResolvedValue({ following: true });
expect((await request(app).put('/api/v2/series/s1/follow')).body.data).toEqual({ following: true });
expect(mockSeries.setFollowing).toHaveBeenCalledWith('s1', 'u1', true);
});
test('a coupon check is always a 200 with valid true or false', async () => {
mockDeals.checkCode.mockResolvedValueOnce({ valid: false, message: 'This code has expired.' });
const res = await request(app).post('/api/v2/coupons/check').send({ code: 'OLD', eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
expect(res.status).toBe(200);
expect(res.body.data).toEqual({ valid: false, message: 'This code has expired.' });
});
test('/coupons/redemptions is not read as a coupon id', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockDeals.listRedemptions.mockResolvedValue([]);
expect((await request(app).get('/api/v2/coupons/redemptions?eventIds=e1,e2')).body).toEqual({ data: [] });
expect(mockDeals.listRedemptions.mock.calls[0][0]).toEqual({ eventIds: ['e1', 'e2'], from: null, to: null });
expect(mockDeals.getCoupon).not.toHaveBeenCalled();
});
test("a campus that can't be deleted says why, as a reason", async () => {
signedIn = { id: 'a1', role: 'admin' };
mockCampuses.deleteCampus.mockRejectedValueOnce(new ServiceError(409, 'North has 3 events, so it can\'t be deleted. Archive it instead.', 'CAMPUS_HAS_EVENTS'));
const res = await request(app).delete('/api/v2/campuses/c1');
expect(res.status).toBe(409);
expect(res.body.error).toEqual({ code: 'CONFLICT', message: expect.stringContaining('Archive it instead'), reason: 'CAMPUS_HAS_EVENTS' });
});
test('campus admin routes are for admins; the public list is for anyone', async () => {
mockCampuses.publicCampuses.mockResolvedValue({ enabled: true, campuses: [{ id: 'c1', name: 'North', slug: 'north', colour: null }] });
expect((await request(app).get('/api/v2/campuses')).body.data.campuses[0]).toMatchObject({ slug: 'north' });
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).get('/api/v2/campuses/admin')).status).toBe(403);
expect((await request(app).put('/api/v2/campuses/enabled').send({ enabled: true })).status).toBe(403);
});
});
describe('managing registrations', () => {
const reg = { id: 'r1', status: 'pending', eventId: 'e1', userId: 'u1', totalDueComputed: 150, payments: [] };
test('a guest with the link can read a registration in full', async () => {
mockAdmin.getRegistrationById.mockResolvedValue(reg);
const res = await request(app).get('/api/v2/registrations/r1/full');
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({ id: 'r1', totalDueComputed: 150, payments: [] });
expect(mockAdmin.getRegistrationById.mock.calls[0][0]).toMatchObject({ actor: null, params: { id: 'r1' } });
});
test('the staff list is paged and campus-scoped', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/registrations')).status).toBe(403);
signedIn = { id: 's1', role: 'staff' };
mockAdmin.getRegistrations.mockResolvedValue({ data: [reg], total: 1, page: 1, limit: 100, pages: 1 });
const res = await request(app).get('/api/v2/registrations?unpaid=true');
expect(res.body).toEqual({ data: [reg], meta: { page: 1, limit: 100, total: 1, pages: 1 } });
const call = mockAdmin.getRegistrations.mock.calls[0][0];
expect(call.query).toMatchObject({ unpaid: '1', page: '1', limit: '100' });
expect(typeof call.listWhere).toBe('function');
});
test('a status must be one of the five', async () => {
signedIn = { id: 's1', role: 'staff' };
const res = await request(app).patch('/api/v2/registrations/r1/status').send({ status: 'refunded' });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(mockAdmin.updateRegistrationStatus).not.toHaveBeenCalled();
});
test('registering someone at the door is for supervisors, and answers 201', async () => {
const body = { eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 2 }], user: { name: 'Thandi', phoneNumber: '0821234567' } };
signedIn = { id: 's1', role: 'staff' };
expect((await request(app).post('/api/v2/registrations/manual').send(body)).status).toBe(403);
signedIn = { id: 'v1', role: 'supervisor' };
mockAdmin.createManualRegistration.mockResolvedValue(reg);
const res = await request(app).post('/api/v2/registrations/manual').send(body);
expect(res.status).toBe(201);
expect(mockAdmin.createManualRegistration.mock.calls[0][0].body).toMatchObject(body);
});
test("a refusal keeps the service's status", async () => {
signedIn = { id: 'u1', role: 'user' };
mockAdmin.updateRegistrationOptions.mockRejectedValueOnce(new ServiceError(400, 'Cannot reduce items below the amount already paid'));
const res = await request(app).put('/api/v2/registrations/r1/items').send({ options: [{ eventOptionId: 'o1', quantity: 1 }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatchObject({ code: 'VALIDATION_FAILED', message: 'Cannot reduce items below the amount already paid' });
});
});
+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"]
}
+213 -9
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.48.2** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.10** (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,21 @@ 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.10 | **Managing registrations on v2** — `src/services/registrationAdmin.ts` (the rest of `registrationController.js`); frontend `fetchAllRegistrations()`; money split out to 1.51.11 |
| 1.51.9 | **Series, season passes, bundles, coupons, campuses on v2** — `src/services/series.ts`, `deals.ts`, `campuses.ts`; `reason` on v2 errors from service codes; frontend `apiV2Data()` |
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
| 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` |
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
@@ -55,8 +70,19 @@ 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.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
and 1.51.0 accessibility.
1. **1.51.11 – 1.51.16: 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.16 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:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
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
@@ -67,20 +93,22 @@ 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 11 (1.51.7) runs in its own schema,
`pgboss_v11` — it can't migrate pg-boss 10's `pgboss` schema; there's no `archive` table any more
(finished runs stay in `job` until they expire).
- **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
@@ -377,6 +405,184 @@ a test server (both done 2026-10-28).
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
scoping).
## Where the dashboard and palette live (1.49.0)
- `backend/src/utils/dashboard.js` — `todaysEvents`, `moneyIn`, `pendingItems` (each item counted
separately, failures logged and skipped; role-only items for admins), `search` (+ `amountOf`).
Everything goes through `eventWhere(scopeFor(req))`. Routes: `routes/dashboardRoutes.js`.
**A new "something needs doing" count** goes in `pendingItems` (with its link and the `order`).
- Frontend: `components/dashboard/TodayPanel.tsx`, `components/shared/CommandPalette.tsx` (mounted in
`app/dashboard/layout.tsx`; `CommandPaletteButton` on the dashboards), `lib/commandPages.ts` —
**a new page or setting tab should be added there** with its roles.
- Deep links read `window.location.search` once on load (no Suspense needed).
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
## Where bulk actions live (1.49.1)
- `backend/src/utils/bulkRegistrations.js` — `runBulk` checks the input (≤ 500, licence read-only for
payments and moves) and runs one action per registration, collecting a result for each. Most
actions **call the existing Express handlers in-process** (`call(handler, req)`, with the original
request's user, headers and socket so audits keep the IP): `createPayment`, `cancelRegistration`,
`allocationController.assign`, `emailEventAttendees` / `whatsappEventAttendees` (new
`filter.registrationIds`). **Change a rule in those handlers and bulk follows.**
- Moving tickets is its own code (`moveOption`): the edit handler refuses to remove issued tickets,
so the lines are re-pointed in place (tickets and QR codes kept), repriced with `resolveOptionPrice`,
attendees moved, then `updateRegistrationStatus`.
- Frontend: `components/admin/RegistrationBulkBar.tsx` on `app/dashboard/admin/registrations`. Export
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
`bulk.spec.ts`.
## Saved filters (1.49.2)
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
- e2e `saved-filters.spec.ts` (deletes what it saves).
## Accessibility (1.50.0)
- **Popups:** use `components/shared/ModalOverlay.tsx` (`labelledBy` = the heading's id, `onClose`,
`closeOnBackdrop`) instead of a bare `fixed inset-0` div — it gives the dialog role, focus trap,
Escape and focus return. Radix `ui/dialog` is fine too.
- **Fields:** tie every visible `<label>` to its control (`htmlFor`/`id`, or wrap the control); in
lists use an id with the row key, or `aria-label`. Icon-only buttons need `aria-label`.
- **Text colour:** `text-gray-500` is the lightest grey for text on white (`gray-400` fails 4.5:1).
Links inside a `<p>`/`<label>` are underlined by `globals.css`.
- **Tests:** `e2e/accessibility.spec.ts` — add new public/member pages to it. It checks with axe's
WCAG 2.0–2.2 A/AA tags; the app has no dark mode, so only the light theme is checked.
- **Not covered yet:** the staff, supervisor and admin screens (scanner, at-the-door, settings …)
weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made
popups. Do them when those pages are next worked on.
## Long lists (1.50.1)
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
payments, audit log, consent/access logs, data requests, My payments.
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
(`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; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
## Managing registrations on v2 (1.51.10)
- **`registrationAdmin.ts` takes a `Call`** — `{ actor, params, body, query, readOnly, ip, listWhere }`
— and returns what v1 answered, because these handlers were moved nearly as they were (about
1,400 lines). v1 builds it with `callOf(req)`, v2 with `callFrom(req, user, parts)` in
`resources/registrationsManage.ts`. Lists that are campus-scoped call `call.listWhere()`, which
refuses (500) when a caller didn't give one, so nothing is listed unscoped by mistake.
- Errors a JS helper throws without a status were 400s on v1; `as400` keeps them 400 on v2
(TypeErrors and the like stay 500s).
- The staff list is paged on v2 (up to 200 a page); screens that need everything use
`fetchAllRegistrations(token, { unpaid?, userId? })`.
## Series, deals and campuses on v2 (1.51.9)
- **`apiV2Data(method, path, opts)`** (`lib/apiV2.ts`) returns `data` untyped for screens that keep
their own record types: `await (apiV2Data("get", "/coupons", { authToken }) as Promise<Coupon[]>)`.
TypeScript can't infer the method and path when a result type is given, so cast instead.
- **Codes in service errors** (`fail(409, message, 'CAMPUS_HAS_EVENTS')`) reach v2 clients as
`error.reason`, and v1 controllers still send them as `code`.
- Answers that aren't failures (a coupon that doesn't apply) are `200` with a flag on v2.
## The rest of event set-up on v2 (1.51.8)
- **One event:** `fetchEvent(id, { authToken, nextOptions })` (`lib/api.ts`, v2 `GET /events/{id}/full`) —
never `/api/events/:id`. Server pages pass `nextOptions` for revalidation.
- **Set-up calls:** `lib/eventSetup.ts` (ticket options, sections, attachments, costs, `uploadImage()`,
which returns the path to save).
- **Downloads and uploads on v2:** `file: { contentType }` + `response: fileResponse` makes a route
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
`middleware`; check `req.multerError`/`req.file` in the handler).
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
email/WhatsApp senders (messaging, 1.51.14).
## Setting events up on v2 (1.51.7)
- **Staff event lists:** use `fetchManagedEvents(token, { past, inactive, closed, campus })`
(`lib/api.ts`, v2 `GET /events/manage`), never `/api/events/all`.
- **Loose records:** staff endpoints that return whole stored records (events now; more to come)
type the core fields and pass the rest through (`z.looseObject`), serialised with
`JSON.parse(JSON.stringify(...))` so dates are ISO strings. Public and member endpoints stay strict.
- **Campus scope on v2:** reuse the v1 middleware with `middleware: [scopeEvent({ need: 'manage' })]`
— its `{ message }` refusals become v2 errors through `legacyErrorsToV2`.
- **Route order:** fixed paths (`/events/manage`) must be defined before `/events/:id` —
`eventsManage.ts` is imported before `events.ts` in `src/v2/index.ts`.
## 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
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
@@ -387,8 +593,6 @@ a test server (both done 2026-10-28).
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
- The audit-log page's action filter list was already missing newer actions before 1.38.0
(only the three privacy ones were added).
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
+59 -13
View File
@@ -35,8 +35,15 @@ up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticke
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. **Next: 1.49.0 Admin dashboard, command
palette and bulk actions.**
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** 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** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
**series, passes, deals and campuses on v2** and 1.51.10 **managing registrations on v2** shipped. **Next: 1.51.11 –
1.51.16 (every endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -58,7 +65,7 @@ palette and bulk actions.**
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
@@ -517,6 +524,9 @@ register; deliveries kept 30 days.)
## 9. Admin dashboard, command palette, bulk actions
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
outstanding, waitlist offers, data requests), alerts.
@@ -530,6 +540,8 @@ register; deliveries kept 30 days.)
## 10. Languages (Afrikaans first)
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
- User language preference; emails, WhatsApp messages and PDFs sent in the
user's language, with per-language templates.
@@ -546,6 +558,9 @@ register; deliveries kept 30 days.)
fields.
- Automated `axe` checks in the Playwright suite so it doesn't regress.
**Shipped in 1.50.0** for the pages above. The app has no dark mode, so only the light theme is
checked. Staff/supervisor/admin screens are left for when they're next worked on (see HANDOVER).
---
## 12. Multi-campus (one install per church)
@@ -868,11 +883,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.
@@ -893,7 +931,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
@@ -936,8 +979,7 @@ each confirmed action in the audit log.
### More helpers
Smaller one-button additions as they come up: suggest an event's price tiers from past
events, write a post-event thank-you, draft certificate wording, translate a message into
Afrikaans (ties in with §10), flag unusual payments for the cashup.
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
### Self-hosted AI (long term)
@@ -968,9 +1010,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.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.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.10 ✅, 1.51.11 – 1.51.16 | **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 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **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
@@ -1007,6 +1050,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.7 – 1.51.16, 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.16); 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) |
@@ -1020,6 +1065,7 @@ feature.
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
+134
View File
@@ -0,0 +1,134 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
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.
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
async function check(page: Page, label: string) {
// Let animations and late-loading data settle so colours are measured as shown.
await page.waitForLoadState("networkidle");
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const summary = results.violations.map((v) => ({
id: v.id,
help: v.help,
nodes: v.nodes.map((n) => `${n.target.join(" ")} — ${n.failureSummary?.split("\n").slice(0, 2).join(" ")}`),
}));
expect.soft(summary, `${label}: accessibility violations`).toEqual([]);
}
async function visit(page: Page, url: string, label: string, ready?: () => Promise<void>) {
await page.goto(url);
if (ready) await ready();
await check(page, label);
}
test.describe("accessibility (WCAG 2.2 AA)", () => {
test("public pages", async ({ page }) => {
await visit(page, "/", "home");
await visit(page, "/events", "events", () => expect(page.getByText("E2E Free Evening")).toBeVisible());
await visit(page, `/events/${seed().events.course}`, "event page", () =>
expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible(),
);
await visit(page, `/events/${seed().events.dropIn}`, "drop-in event page");
await visit(page, "/contact", "contact");
await visit(page, "/legal/privacy", "privacy notice");
await visit(page, "/legal/terms", "terms");
});
test("sign in, sign up and forgot password, with errors", async ({ page }) => {
await visit(page, "/login", "login");
await page.getByLabel("Email or phone").fill(seed().users.user.email);
await page.getByLabel("Password", { exact: true }).fill("not-the-password");
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByRole("alert")).toBeVisible();
await check(page, "login with an error");
await visit(page, "/register", "sign up");
await visit(page, "/forgot-password", "forgot password");
});
test("registration form", async ({ page }) => {
await loginAs(page, "user");
await visit(page, `/register/${seed().events.paid}`, "registration", async () => {
await page.getByRole("button", { name: /^Add one / }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
});
await visit(page, `/register/${seed().events.dropIn}`, "registration (pick sessions)");
});
test("My Events and account pages", async ({ page }) => {
await loginAs(page, "user");
await visit(page, "/dashboard/user", "My Events");
await visit(page, "/dashboard/user/profile", "profile");
await visit(page, "/dashboard/user/privacy", "privacy centre");
await visit(page, "/dashboard/user/payments", "payments");
await visit(page, "/dashboard/user/kids", "kids");
await visit(page, "/dashboard/user/serving", "serving");
});
test("self-service kiosk", async ({ page }) => {
await visit(page, "/self-service", "kiosk sign-in");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
await check(page, "kiosk event picker");
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");
// "Create an account" is a switch you can reach and flip with the keyboard.
const create = page.getByRole("switch", { name: /Create an account/ });
await create.focus();
await page.keyboard.press("Space");
await expect(create).toHaveAttribute("aria-checked", "true");
await expect(page.getByLabel("Choose a password")).toBeVisible();
});
});
test.describe("keyboard", () => {
test("the first Tab reaches “Skip to main content”, which moves focus to the page content", async ({ page }) => {
await page.goto("/events");
await expect(page.getByText("E2E Free Evening")).toBeVisible();
await page.keyboard.press("Tab");
const skip = page.getByRole("link", { name: "Skip to main content" });
await expect(skip).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("main").first()).toBeFocused();
});
test("a popup keeps focus inside, closes with Escape and hands focus back", async ({ page }) => {
await page.goto("/self-service");
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();
await pickEvent(page, "E2E Free Evening");
await page.getByRole("button", { name: "Start Kiosk" }).click();
const opener = page.getByRole("button", { name: "Change Event" });
await opener.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog", { name: "Change Event" });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel("Password", { exact: true })).toBeFocused();
// Tab doesn't leave the popup.
for (let i = 0; i < 4; i++) await page.keyboard.press("Tab");
expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
test("ticket quantities can be changed with the keyboard and are announced by name", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
const add = page.getByRole("button", { name: /^Add one / }).first();
await add.focus();
await page.keyboard.press("Enter");
await expect(page.getByText("Total: R150.00")).toBeVisible();
await expect(page.getByRole("button", { name: /^Remove one / }).first()).toBeEnabled();
});
});
+229
View File
@@ -0,0 +1,229 @@
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"`);
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
// Endpoints with no v2 version yet say nothing.
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const list = await (await request.get(`${API}/api/v2/events/manage`, { headers: staff })).json();
// The kids event is hidden from the public list but staff see it.
expect(list.data.map((e: { id: string }) => e.id)).toContain(seed().events.kids);
expect((await (await request.get(`${API}/api/v2/events?limit=100`)).json()).data.map((e: { id: string }) => e.id)).not.toContain(seed().events.kids);
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const start = new Date(Date.now() + 20 * 864e5);
const made = await request.post(`${API}/api/v2/events`, { headers: sup, data: { title: "E2E v2 Event", description: "Made through v2", price: 0, location: "1 Test Street", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3600e3).toISOString() } });
expect(made.status()).toBe(201);
const ev = (await made.json()).data;
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Main Ticket"]);
const changed = await (await request.patch(`${API}/api/v2/events/${ev.id}`, { headers: sup, data: { title: "E2E v2 Event (renamed)", waitlistEnabled: true } })).json();
expect(changed.data).toMatchObject({ title: "E2E v2 Event (renamed)", waitlistEnabled: true, sessionChanges: null });
expect((await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: sup })).status()).toBe(403);
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
expect((await (await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: admin })).json()).data).toEqual({ id: ev.id, isActive: false });
});
test("an event in full, its calendar file, ticket options, sections and costs", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const full = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
expect(full.eventOptions.length).toBeGreaterThan(0);
expect(full).toHaveProperty("attachments");
// The public don't see who gets the emails; staff do.
expect(full.notifyRecipients).toBeUndefined();
expect((await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`, { headers: sup })).json()).data).toHaveProperty("notifyRecipients");
const ics = await request.get(`${API}/api/v2/events/${seed().events.conference}/calendar.ics`);
expect(ics.headers()["content-type"]).toContain("text/calendar");
expect(await ics.text()).toContain("BEGIN:VCALENDAR");
const opt = (await (await request.post(`${API}/api/v2/events/${seed().events.conference}/ticket-options`, { headers: sup, data: { name: "E2E v2 Extra", price: 25 } })).json()).data;
expect(opt).toMatchObject({ name: "E2E v2 Extra", price: 25, isMainTicket: false });
const withVariant = (await (await request.patch(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup, data: { variants: [{ name: "Large", price: 30 }] } })).json()).data;
expect(withVariant.variants.map((v: { name: string }) => v.name)).toEqual(["Large"]);
expect((await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup })).status()).toBe(403);
expect((await (await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: admin })).json()).data).toEqual({ id: opt.id, deleted: true });
const section = (await (await request.post(`${API}/api/v2/sections`, { headers: sup, data: { eventId: seed().events.conference, name: "E2E Side door", allowedOptionIds: [full.eventOptions[0].id] } })).json()).data;
expect(section.allowedOptions).toHaveLength(1);
const listed = (await (await request.get(`${API}/api/v2/sections?eventId=${seed().events.conference}`, { headers: sup })).json()).data;
expect(listed.map((x: { id: string }) => x.id)).toContain(section.id);
const cost = await request.post(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: admin, data: { label: "E2E Hall", costType: "once_off", amount: 500 } });
expect(cost.status()).toBe(201);
const costs = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: sup })).json()).data;
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
});
test("series, coupon codes, bundle quotes and campuses", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const made = await request.post(`${API}/api/v2/series`, { headers: sup, data: { name: "E2E v2 Series", description: "Made through v2" } });
expect(made.status()).toBe(201);
const series = (await made.json()).data;
const renamed = (await (await request.patch(`${API}/api/v2/series/${series.id}`, { headers: sup, data: { name: "E2E v2 Series (renamed)" } })).json()).data;
expect(renamed.name).toBe("E2E v2 Series (renamed)");
const page = (await (await request.get(`${API}/api/v2/series/by-slug/${series.slug}`, { headers: user })).json()).data;
expect(page).toMatchObject({ id: series.id, following: false, upcoming: [], past: [] });
expect((await (await request.put(`${API}/api/v2/series/${series.id}/follow`, { headers: user })).json()).data).toEqual({ following: true });
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
const opt = ev.eventOptions[0];
const code = await request.post(`${API}/api/v2/coupons`, { headers: sup, data: { code: "E2EV2TEN", type: "percent", value: 10, eventIds: [ev.id] } });
expect(code.status()).toBe(201);
const basket = { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }] };
const ok = (await (await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "E2EV2TEN" } })).json()).data;
expect(ok).toMatchObject({ valid: true, code: "E2EV2TEN" });
const bad = await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "NO-SUCH-CODE" } });
expect(bad.status()).toBe(200);
expect((await bad.json()).data.valid).toBe(false);
const quote = (await (await request.post(`${API}/api/v2/bundles/quote`, { data: basket })).json()).data;
expect(quote).toMatchObject({ bundleDiscount: 0 });
expect(quote.total).toBe(quote.subtotal);
const campuses = (await (await request.get(`${API}/api/v2/campuses`)).json()).data;
// On or off depending on which tests ran first: when off, the list is empty.
if (campuses.enabled) expect(campuses.campuses.map((c: { slug: string }) => c.slug)).toEqual(expect.arrayContaining([seed().campuses.north.slug]));
else expect(campuses.campuses).toEqual([]);
expect((await request.get(`${API}/api/v2/campuses/admin`, { headers: sup })).status()).toBe(403);
});
test("staff register someone at the door and manage the registration", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
const opt = ev.eventOptions[0];
const made = await request.post(`${API}/api/v2/registrations/manual`, {
headers: sup,
data: { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }], user: { name: "E2E Door Guest", email: "e2e-v2-door@example.com" }, skipActivationNotice: true },
});
expect(made.status()).toBe(201);
const reg = (await made.json()).data;
expect(reg).toMatchObject({ eventId: ev.id, status: expect.any(String) });
const list = (await (await request.get(`${API}/api/v2/events/${ev.id}/registrations?search=e2e-v2-door`, { headers: sup })).json()).data;
expect(list.map((r: { id: string }) => r.id)).toContain(reg.id);
const full = (await (await request.get(`${API}/api/v2/registrations/${reg.id}/full`, { headers: sup })).json()).data;
expect(full.registrationOptions[0]).toMatchObject({ eventOptionId: opt.id, quantity: 1 });
const bad = await request.patch(`${API}/api/v2/registrations/${reg.id}/status`, { headers: sup, data: { status: "refunded" } });
expect(bad.status()).toBe(400);
expect((await bad.json()).error.code).toBe("VALIDATION_FAILED");
const page = await (await request.get(`${API}/api/v2/registrations?limit=2`, { headers: sup })).json();
expect(page.meta).toMatchObject({ page: 1, limit: 2 });
expect(page.data.length).toBeLessThanOrEqual(2);
// Tidy up: cancelled registrations don't count towards anything.
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: admin });
expect((await cancelled.json()).data.status).toBe("cancelled");
});
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.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect, loginAs, api } from "./fixtures";
// Bulk actions on the registrations list (1.49.1): tick several, mark them paid, move them to
// another ticket, cancel — each through the same rules as one at a time.
type Ev = { id: string; eventOptions?: { id: string; name: string; isMainTicket?: boolean }[] };
type Reg = { id: string; status: string; registrationOptions?: { eventOptionId: string; quantity: number }[]; payments?: { amount: number; method: string }[] };
test.describe.serial("bulk actions on registrations", () => {
let event: Ev;
let main: string;
let vip: string;
const regs: string[] = [];
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
const start = new Date(Date.now() + 20 * 864e5);
event = await api<Ev>(page, "admin", "POST", "/api/events", {
title: "E2E Bulk Night", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3 * 3600e3).toISOString(),
price: 100, location: "Church hall",
});
const full = await api<Ev>(page, "admin", "GET", `/api/events/${event.id}`);
main = full.eventOptions!.find(o => o.isMainTicket)!.id;
vip = (await api<{ id: string }>(page, "admin", "POST", `/api/events/${event.id}/options`, { name: "VIP", price: 150, isMainTicket: true })).id;
for (const role of ["staff", "supervisor", "user"] as const) {
regs.push((await api<{ id: string }>(page, role, "POST", "/api/registrations", { eventId: event.id, options: [{ eventOptionId: main, quantity: 1 }] })).id);
}
await page.close();
});
const openList = async (page: import("@playwright/test").Page) => {
await loginAs(page, "admin");
await page.goto(`/dashboard/admin/registrations?event=${event.id}`);
await expect(page.getByText("3 of", { exact: false })).toBeVisible();
};
test("tick all and mark them paid in cash", async ({ page }) => {
await openList(page);
await page.getByLabel("Select all shown").check();
const bar = page.getByTestId("bulk-bar");
await expect(bar).toContainText("3 selected");
await bar.getByLabel("Bulk action").selectOption("markPaid");
await bar.getByLabel("Paid by").selectOption("cash");
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Mark paid/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("3 done");
for (const id of regs) {
const r = await api<Reg>(page, "admin", "GET", `/api/registrations/${id}`);
expect(r.status).toBe("paid");
expect(r.payments?.map(p => [p.amount, p.method])).toEqual([[100, "cash"]]);
}
});
test("move two to the VIP ticket; cancel the third", async ({ page }) => {
await openList(page);
const rows = page.locator("li").filter({ has: page.getByRole("checkbox", { name: /^Select / }) });
await rows.nth(0).getByRole("checkbox").check();
await rows.nth(1).getByRole("checkbox").check();
const bar = page.getByTestId("bulk-bar");
await bar.getByLabel("Bulk action").selectOption("moveOption");
await bar.getByLabel("Move from").selectOption(main);
await bar.getByLabel("Move to").selectOption(vip);
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Move to another ticket/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("2 done");
const moved = await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)));
const onVip = moved.filter(r => r.registrationOptions?.some(o => o.eventOptionId === vip));
expect(onVip).toHaveLength(2);
// R150 now, R100 paid: they owe the difference.
expect(onVip.every(r => r.status === "partial_paid")).toBe(true);
await bar.getByRole("button", { name: "Clear selection" }).click();
const left = moved.find(r => !r.registrationOptions?.some(o => o.eventOptionId === vip))!;
await page.getByRole("checkbox", { name: /^Select / }).nth(2).check();
const selectedId = (await api<Reg>(page, "admin", "GET", `/api/registrations/${left.id}`)).id;
expect(selectedId).toBe(left.id);
const bar2 = page.getByTestId("bulk-bar");
await bar2.getByLabel("Bulk action").selectOption("cancel");
page.once("dialog", d => d.accept());
await bar2.getByRole("button", { name: /Cancel — 1/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("1 done");
const cancelled = (await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)))).filter(r => r.status === "cancelled");
expect(cancelled).toHaveLength(1);
});
});
+90
View File
@@ -0,0 +1,90 @@
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 }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin");
const today = page.getByTestId("today-panel");
await expect(today).toBeVisible();
// Seeded on today: the hidden kids church and serving Sunday.
await expect(today.getByTestId("today-events")).toContainText("E2E Serving Sunday");
await expect(today.getByText("Waiting for you")).toBeVisible();
});
test("Ctrl+K finds an event and opens it", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByTestId("today-panel")).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await expect(dialog).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("Paid Conference");
await expect(dialog.getByRole("option", { name: /E2E Paid Conference/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/dashboard\/supervisor\/events/);
await expect(page.getByRole("heading", { name: "Edit Event" })).toBeVisible();
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Paid Conference");
});
test("the palette's actions and pages follow the role", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await page.getByRole("button", { name: /Search or jump to/ }).click();
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("new event");
await dialog.getByRole("option", { name: "New event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
// 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");
await expect(staffDialog.getByRole("option", { name: "Users" })).toHaveCount(0);
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();
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
// 1.50.1: long lists load a page (or just what's needed) at a time, with search and filters.
test.describe("long lists", () => {
test("Payments: today, recent and a person's payments for a refund", async ({ page }) => {
// A donation recorded today for the seeded user (on an event no other test totals up).
await api(page, "supervisor", "POST", "/api/payments", {
amount: 77, method: "cash", isDonation: true, eventId: seed().events.conference, userId: seed().users.user.id,
});
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await expect(page.getByText("R 77.00").first()).toBeVisible();
// Refund: picking the person loads their payments.
await page.locator("label", { hasText: "Refund" }).first().click();
await page.getByPlaceholder("Search by name, email or phone…").last().fill(seed().users.user.name);
await page.getByRole("button", { name: new RegExp(seed().users.user.name) }).first().click();
await expect(page.locator("option", { hasText: "R 77.00" })).toHaveCount(1);
});
test("Payments API: narrowing filters", async ({ page }) => {
const userId = seed().users.user.id;
const mine = await api<{ data: { userId: string }[] }>(page, "supervisor", "GET", `/api/payments?userId=${userId}&limit=200`);
expect(mine.data.length).toBeGreaterThan(0);
expect(mine.data.every((p) => p.userId === userId)).toBe(true);
const unpaid = await api<{ status: string }[]>(page, "supervisor", "GET", "/api/registrations?unpaid=1");
expect(unpaid.every((r) => ["pending", "confirmed", "partial_paid"].includes(r.status))).toBe(true);
});
test("Audit log: search and the full list of actions", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/audit-log");
const action = page.getByLabel("Action");
await expect(action.locator("option", { hasText: "Bulk action on registrations" })).toHaveCount(1);
await expect(action.locator("option", { hasText: "Webhook added, changed or deleted" })).toHaveCount(1);
await page.getByLabel("Search").fill("nobody-matches-this-xyz");
await expect(page.getByText("No matching audit entries.")).toBeVisible();
await expect(page.getByText(/0 actions match the filters/)).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByLabel("Search")).toHaveValue("");
});
test("Data requests: search", async ({ page }) => {
const res = await api<{ requests: unknown[]; total: number }>(page, "admin", "GET", "/api/privacy/requests?status=closed&search=nobody-matches-this-xyz");
expect(res.requests).toEqual([]);
expect(res.total).toBe(0);
});
});
+6 -7
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 };
@@ -8,7 +8,7 @@ test.describe("pick sessions", () => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.dropIn}`);
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await expect(picker.getByText("Full")).toBeVisible();
@@ -50,7 +50,7 @@ test.describe("pick sessions", () => {
test("join the waitlist for a full class", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/register/${seed().events.dropIn}`);
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("button", { name: "Join waitlist" }).click();
@@ -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();
@@ -80,7 +79,7 @@ test.describe("pick sessions", () => {
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await expect(page.getByText("R 50.00 per session")).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(1).check();
await expect(page.getByText("Total: R 50.00")).toBeVisible();
+2 -2
View File
@@ -10,7 +10,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
await loginAs(page, "user");
await page.goto(`/register/${seed().events.free}`);
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
@@ -21,7 +21,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
+33
View File
@@ -0,0 +1,33 @@
import { test, expect, loginAs } from "./fixtures";
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
// when the page opens), and delete them again.
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/registrations");
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
await status.selectOption("cancelled");
const saved = page.getByTestId("saved-filters");
await saved.getByRole("button", { name: /Saved filters/ }).click();
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
await saved.getByLabel("Use when this page opens").check();
await saved.getByRole("button", { name: "Save", exact: true }).click();
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
// The default is applied when the page opens…
await page.reload();
await expect(status).toHaveValue("cancelled");
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
// …but not over a link that says what to show.
await page.goto("/dashboard/admin/registrations?status=paid");
await expect(status).toHaveValue("paid");
await saved.getByRole("button", { name: /Saved filters/ }).click();
page.once("dialog", d => d.accept());
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
await expect(saved.getByText("No saved filters yet")).toBeVisible();
await page.goto("/dashboard/admin/registrations");
await expect(status).toHaveValue("");
});
+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();
});
+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 EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
@@ -80,15 +80,14 @@ 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();
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).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;

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