Compare commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:18:29 +02:00
joshuaandClaude Opus 5.5 99efe7a7d0 WIP 1.51.8: frontend on v2 for event detail, options, sections, attachments, costs, uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:14:01 +02:00
joshuaandClaude Opus 5.5 3c983070a1 WIP 1.51.8: sections, costs and uploads on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:08:03 +02:00
joshuaandClaude Opus 5.5 c63c3a3604 WIP 1.51.8: event detail, options, variants, attachments, calendar and alias on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:04:06 +02:00
joshua 67d0a83ab8 Merge branch 'feat/api-v2-events' 2026-10-29 11:57:08 +02:00
193 changed files with 71277 additions and 5049 deletions
+220
View File
@@ -7,6 +7,226 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
(`/payments/yoco/…`, `/payments/payfast/…`).
- `v1Route` handles `res.download` and `res.sendFile`.
### Changed
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
- Ending a viewing-as session works on v2 while the session is read-only.
## [1.51.14] - 2026-10-29
### Added
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
- `defineRoute` refuses a method and path defined twice (the first would silently win).
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
### Changed
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
reports and the public event lists use v2.
## [1.51.13] - 2026-10-29
### Added
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
### Changed
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
## [1.51.12] - 2026-10-29
### Added
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
read v2's error shape.
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
404 instead of waiting.
### Changed
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
set-up use v2.
## [1.51.11] - 2026-10-29
### Added
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
assigned to registrations, stats, providers and their settings, open checkouts), payment links
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
feedback (set-up, send now, the summary, the form by link).
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
v1's `code` as `reason`; piped files become downloads.
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
### Changed
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
and donate pages, the user dashboard) uses v2.
## [1.51.10] - 2026-10-29
### Added
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
attendees and their form links, attendee form drafts and responses
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
`registrationController.js` (now about 60 lines of v1 wrappers).
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
door, manual registration, self-service, payments, forms, the attendee composers, reports and
the user dashboard all use v2.
### Changed
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
and links are now 1.51.16.
## [1.51.9] - 2026-10-29
### Added
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
bundle, coupon and campus controllers are now thin v1 wrappers.
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
middleware's codes.
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
coupon field and bundle quotes on the register page all use v2.
### Changed
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
coupon field shows that reason as before.
## [1.51.8] - 2026-10-29
### Added
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
are described as multipart in the OpenAPI document.
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
### Changed
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
path; old manifests can still be imported with `/attachments/sync`.
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
(to 1.51.15).
## [1.51.7] - 2026-10-29
### Added
+1 -1
View File
@@ -137,7 +137,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
+57
View File
@@ -12,6 +12,63 @@ 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.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
as before.
## 1.51.14
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
saved filters and report files now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.13
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
the server's connection to the app. They work just as before.
## 1.51.12
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
certificates and group allocations now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.11
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
feedback now run on the new version of the server's connection to the app. They work just as
before.
## 1.51.10
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
bulk actions — now run on the new version of the server's connection to the app. They work
just as before.
## 1.51.9
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
now run on the new version of the server's connection to the app. They work just as before.
## 1.51.8
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
costs and picture uploads now run on the new version of the server's connection to the app. They
work just as before.
## 1.51.7
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.7",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.7",
"version": "1.51.17",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.7",
"version": "1.51.17",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+8 -2
View File
@@ -99,8 +99,10 @@ const globalLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
skip: (req) => RAW_BODY_PREFIXES.some(p => req.path.startsWith(p)),
});
// Incoming webhooks and links in sent messages on v2 (1.51.16): see src/v2/links.ts.
const { RAW_BODY_PREFIXES, webhooksRouter, linksRouter, legacyLinkRedirects } = require('./v2/links');
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
@@ -115,7 +117,7 @@ app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
if (RAW_BODY_PREFIXES.some(p => req.path.startsWith(p))) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
@@ -161,6 +163,7 @@ const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
app.use('/api/v2/webhooks', webhooksRouter);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
@@ -196,7 +199,10 @@ app.get('/health', async (req, res) => {
// Use routes
// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2.
app.use('/api/v2', linksRouter);
app.use('/api/v2', require('./v2').v2Router());
// Links in messages already sent: v1 addresses answer 308 with their v2 one.
app.use(legacyLinkRedirects);
app.use('/docs/v2', require('./v2').v2DocsRouter());
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
+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 };
+43 -682
View File
@@ -1,20 +1,18 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { assertEventOpen } = require('../utils/cashupUtils');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { publicOptions } = require('../utils/volunteers');
const { campusesEnabled, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView } = require('../utils/campusScope');
const seriesUtil = require('../utils/series');
const { scopeFor } = require('../utils/campusScope');
// Setting events up is shared with /api/v2/events (TypeScript, 1.51.7).
const eventService = require('../services/events');
const { SERIES_SELECT, SESSIONS_INCLUDE, withSchedule } = eventService;
const details = require('../services/eventDetails');
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
// @route POST /api/events/:id/copy-from
@@ -222,173 +220,9 @@ const getEventsAll = async (req, res) => {
// @access Public
const getEventById = async (req, res) => {
try {
const eventId = req.params.id;
// Determine if Prisma Client supports EarlyBirdTier / OptionVariant
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
if (isStaffOrHigherForNotify) {
includeObj.createdBy = { select: { id: true, name: true, email: true } };
includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } };
}
let event;
// Try to include attachments via Prisma if the client has the model
const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
if (canUsePrismaAttachments) {
try {
// @ts-ignore - runtime guard ensures this exists
includeObj.attachments = true;
event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj });
} catch (e) {
// If relation/table does not exist (migration not deployed), fall back to fetching without attachments
try {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
} catch (e2) {
// Notify-recipients relation not migrated yet either — fall back further
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
}
// And we will source attachments from the filesystem manifest below
}
} else {
// No model available on the Prisma client; fetch without attachments
try {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
} catch (e) {
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
}
}
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
// public view of other campuses' events (and not their notification recipients).
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
delete event.createdBy;
delete event.notifyRecipients;
// The kids check-in and volunteer tickets are handed out by staff, never sold.
event.eventOptions = publicOptions(event);
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
}
// goLiveAt may not exist on older schemas — tolerate that read failing without
// affecting the isActive gate above.
let goLiveAt = null;
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
if (goLiveAt && new Date() < goLiveAt) {
res.status(404);
throw new Error('Event not found');
}
}
// Resolve attachments
let attachments = [];
if (event.attachments && Array.isArray(event.attachments)) {
attachments = event.attachments;
} else {
// Fall back to manifest on disk if DB attachments are not available
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (fs.existsSync(manifestPath)) {
const raw = fs.readFileSync(manifestPath, 'utf-8');
const list = JSON.parse(raw) || [];
attachments = list;
}
} catch {}
}
// Try include form definition if available
let form = null;
try {
if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') {
const f = await prisma.eventForm.findUnique({
where: { eventId: eventId },
include: { fields: true },
});
if (f) {
form = {
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,
}))
};
}
}
} catch {}
// Compute availableCount per option and variant
let eventOptionsWithStock = event.eventOptions || [];
try {
if (canIncludeVariants) {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
// Picked-session options: the stock limit is places per session.
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability = {};
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 availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
// Per-variant stock
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
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, variants: variantsWithStock };
}));
}
} catch (e) {
// Non-fatal: fall back to options without stock info
}
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const mapped = withSchedule({
...event,
eventOptions: eventOptionsWithStock,
picture: toAbsoluteUrl(req, event.picture),
attachments: (attachments || []).map(att => ({
...att,
url: toAbsoluteUrl(req, att.url)
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
}, bookings);
res.json(mapped);
res.json(await details.getEventDetail(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) }, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -397,47 +231,12 @@ const getEventById = async (req, res) => {
// @access Public (same visibility gating as getEventById)
const getEventIcs = async (req, res) => {
try {
const eventId = req.params.id;
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, sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
// details shouldn't be enumerable via this route either.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
}
let goLiveAt = null;
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
if (goLiveAt && new Date() < goLiveAt) {
res.status(404);
throw new Error('Event not found');
}
}
const { buildEventIcs } = require('../utils/icsUtils');
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
// being configured without one (e.g. "example.com" instead of "https://example.com").
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
frontendUrl = `https://${frontendUrl}`;
}
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
const { filename, ics } = await details.eventCalendar(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) });
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(ics);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -491,49 +290,14 @@ const deleteEvent = async (req, res) => {
};
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
function parseStaffPrice(value, current = null) {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(value);
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
return n;
}
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
const createEventOption = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
id: uuidv4(),
eventId: req.params.id,
name,
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
}
});
sessionsUtil.forgetSchedule(req.params.id);
res.status(201).json(eventOption);
res.status(201).json(await details.createOption(req.params.id, req.body || {}));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -542,131 +306,9 @@ const createEventOption = async (req, res) => {
// @access Private/Admin
const updateEventOption = async (req, res) => {
try {
const eventOption = await prisma.eventOption.findUnique({
where: { id: req.params.id },
include: { earlyBirdTiers: true }
});
if (!eventOption) {
res.status(404);
throw new Error('Event option not found');
}
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : eventOption.name,
price: price !== undefined ? parseFloat(price) : eventOption.price,
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
}
});
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
// every tier for the option on every save, which silently reassigned each one a brand-new
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
// pointed at nothing, so every past purchase under that tier lost its "early bird"
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
// ids stable across edits so that history stays linked.
if (Array.isArray(earlyBirdTiers)) {
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const et of existingTiers) {
if (incomingIds.includes(et.id)) continue;
// Never delete a tier that's still attributed on past purchases — just leave it
// orphaned from the option's active tier list rather than nulling out history.
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
if (usageCount === 0) {
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
}
}
for (let i = 0; i < earlyBirdTiers.length; i++) {
const t = 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 (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
const data = {
eventOptionId: updatedEventOption.id,
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: require('uuid').v4(), ...data } });
}
}
}
// If variants array provided, upsert variants
if (Array.isArray(variants)) {
const incomingIds = variants.filter(v => v.id).map(v => v.id);
// Delete variants not in the new list (only if they have no registrations)
const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const ev of existingVariants) {
if (!incomingIds.includes(ev.id)) {
const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } });
if (usageCount === 0) {
await prisma.optionVariant.delete({ where: { id: ev.id } });
}
}
}
// Upsert incoming variants
for (let i = 0; i < variants.length; i++) {
const v = variants[i];
if (!v.name) continue;
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
const variantStaffPrice = parseStaffPrice(v.staffPrice);
if (v.id) {
await prisma.optionVariant.update({
where: { id: v.id },
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
});
} else {
await prisma.optionVariant.create({
data: {
id: require('uuid').v4(),
eventOptionId: updatedEventOption.id,
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
}
}
}
if (stockLimit !== undefined || variants !== undefined) {
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
}
const refreshed = await prisma.eventOption.findUnique({
where: { id: updatedEventOption.id },
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
});
res.json(refreshed);
res.json(await details.updateOption(req.params.id, req.body || {}));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -675,49 +317,10 @@ const updateEventOption = async (req, res) => {
// @access Private/Admin
const deleteEventOption = async (req, res) => {
try {
const eventOption = await prisma.eventOption.findUnique({
where: { id: req.params.id }
});
if (!eventOption) {
res.status(404);
throw new Error('Event option not found');
}
await assertEventOpen(eventOption.eventId, res);
// If deleting a main ticket, ensure at least one other main ticket exists for the same event
if (eventOption.isMainTicket) {
const otherMainCount = await prisma.eventOption.count({
where: {
eventId: eventOption.eventId,
isMainTicket: true,
NOT: { id: eventOption.id },
}
});
if (otherMainCount === 0) {
res.status(400);
throw new Error('Cannot delete the only main ticket option');
}
}
// Check if there are any registrations using this option
const registrationOptions = await prisma.registrationOption.findMany({
where: { eventOptionId: req.params.id }
});
if (registrationOptions.length > 0) {
res.status(400);
throw new Error('Cannot delete event option that has registrations');
}
await prisma.eventOption.delete({
where: { id: req.params.id }
});
await details.deleteOption(req.params.id);
res.json({ message: 'Event option removed' });
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -726,25 +329,9 @@ const deleteEventOption = async (req, res) => {
// @access Private/Supervisor
const createOptionVariant = async (req, res) => {
try {
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
await assertEventOpen(eventOption.eventId, res);
const { name, price, stockLimit, order, staffPrice } = req.body;
if (!name) { res.status(400); throw new Error('Variant name is required'); }
const variant = await prisma.optionVariant.create({
data: {
id: uuidv4(),
eventOptionId: req.params.id,
name,
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
order: order !== undefined ? parseInt(order, 10) : 0,
}
});
res.status(201).json(variant);
res.status(201).json(await details.createVariant(req.params.id, req.body || {}));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -753,23 +340,9 @@ const createOptionVariant = async (req, res) => {
// @access Private/Supervisor
const updateOptionVariant = async (req, res) => {
try {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const { name, price, stockLimit, order, staffPrice } = req.body;
const updated = await prisma.optionVariant.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : variant.name,
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
order: order !== undefined ? parseInt(order, 10) : variant.order,
}
});
res.json(updated);
res.json(await details.updateVariant(req.params.id, req.body || {}));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -778,25 +351,19 @@ const updateOptionVariant = async (req, res) => {
// @access Private/Admin
const deleteOptionVariant = async (req, res) => {
try {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } });
if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); }
await prisma.optionVariant.delete({ where: { id: req.params.id } });
await details.deleteVariant(req.params.id);
res.json({ message: 'Variant deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// File upload setup for attachments (PDFs/docs)
// File upload for attachments (PDFs/docs), shared with /api/v2/events/{id}/attachments.
const attachmentsStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
if (!fs.existsSync(details.ATTACHMENTS_DIR)) fs.mkdirSync(details.ATTACHMENTS_DIR, { recursive: true });
cb(null, details.ATTACHMENTS_DIR);
} catch (err) {
cb(err);
}
@@ -805,48 +372,31 @@ const attachmentsStorage = multer.diskStorage({
// Extension only — file.originalname is untrusted and joining it into a
// path allows `../` traversal to write outside the upload directory.
const ext = path.extname(file.originalname).toLowerCase();
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
cb(null, unique);
cb(null, `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
}
});
const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
const uploadAttachmentMulter = multer({
storage: attachmentsStorage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!allowedDocs.includes(ext)) {
return cb(new Error('Unsupported file type'));
}
if (!details.ALLOWED_DOCS.includes(path.extname(file.originalname).toLowerCase())) return cb(new Error('Unsupported file type'));
cb(null, true);
}
});
// Saves the upload (field "file"); an error is kept on req for the handler to report.
const receiveAttachment = (req, res, next) => uploadAttachmentMulter.single('file')(req, res, (e) => {
if (e) req.multerError = e;
next();
});
// @desc List attachments for an event
// @route GET /api/events/:id/attachments
// @access Private/Supervisor (for dashboard) and could be public via event details
const listEventAttachments = async (req, res) => {
try {
const eventId = req.params.id;
// If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) {
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (!fs.existsSync(manifestPath)) return res.json([]);
const raw = fs.readFileSync(manifestPath, 'utf-8');
const list = JSON.parse(raw);
return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
} catch (e) {
// If manifest is corrupt or unreadable, return empty list rather than failing
return res.json([]);
}
}
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
res.json(await details.listAttachments(req.params.id, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -854,97 +404,14 @@ const listEventAttachments = async (req, res) => {
// @route POST /api/events/:id/attachments
// @access Private/Supervisor
const uploadEventAttachment = [
(req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => {
if (err) req.multerError = err;
next();
}),
receiveAttachment,
async (req, res) => {
try {
if (req.multerError) throw req.multerError;
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
const eventId = req.params.id;
const relUrl = `/uploads/event-files/${req.file.filename}`;
// Fallback: if attachments model is not available yet, store in filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) {
try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {}
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
const entry = {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
createdAt: new Date().toISOString()
};
let list = [];
if (fs.existsSync(manifestPath)) {
try {
list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
} catch {}
}
list.unshift(entry);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
} catch (e) {
return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message });
}
}
// Normal path: DB-backed
try {
const created = await prisma.eventAttachment.create({
data: {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
}
});
return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) });
} catch (e) {
const msg = String(e?.message || e || '');
const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021');
if (isMissingTable) {
try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {}
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
const entry = {
id: uuidv4(),
eventId,
originalName: req.file.originalname,
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
url: relUrl,
createdAt: new Date().toISOString()
};
let list = [];
if (fs.existsSync(manifestPath)) {
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {}
}
list.unshift(entry);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
} catch (fallbackErr) {
return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) });
}
}
// Unknown error
throw e;
}
res.status(201).json(await details.recordAttachment(req.params.id, req.file, (u) => toAbsoluteUrl(req, u)));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
}
];
@@ -954,43 +421,10 @@ const uploadEventAttachment = [
// @access Private/Admin or Supervisor
const deleteEventAttachment = async (req, res) => {
try {
const { eventId, attachmentId } = req.params;
// If attachments model is not available yet, fall back to filesystem manifest
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) {
try {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' });
const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
const idx = list.findIndex((x) => x.id === attachmentId);
if (idx === -1) return res.status(404).json({ message: 'Attachment not found' });
const att = list[idx];
// Remove file on disk (best-effort)
try {
const filePath = path.join(uploadDir, att.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
// Remove from manifest
list.splice(idx, 1);
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
return res.json({ message: 'Attachment deleted' });
} catch (e) {
return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message });
}
}
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
if (!att || att.eventId !== eventId) {
return res.status(404).json({ message: 'Attachment not found' });
}
// Remove file on disk (best-effort)
try {
const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
await details.deleteAttachment(req.params.eventId, req.params.attachmentId);
res.json({ message: 'Attachment deleted' });
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -999,52 +433,20 @@ const deleteEventAttachment = async (req, res) => {
// @access Private/Admin
const attachmentsStatus = async (req, res) => {
try {
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
let tableQueryable = false;
let probeError = null;
if (hasModel) {
try {
await prisma.eventAttachment.findFirst({});
tableQueryable = true;
} catch (e) {
probeError = String(e?.message || e);
}
}
// Mask DB URL to avoid leaking secrets
const dbUrl = process.env.DATABASE_URL || '';
let dbInfo = null;
try {
const u = new URL(dbUrl);
dbInfo = {
protocol: u.protocol.replace(':',''),
host: u.hostname,
port: u.port || undefined,
database: (u.pathname || '').replace(/^\//,'') || undefined,
};
} catch {}
res.json({
prismaClientHasEventAttachmentModel: hasModel,
databaseTableQueryable: tableQueryable,
probeError,
database: dbInfo,
});
res.json(await details.attachmentsStatus());
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Admin-triggered sync: import manifest files into DB
// @route POST /api/events/attachments/sync
// @access Private/Admin
const { syncManifestsToDb } = require('../utils/attachmentsSync');
const attachmentsSync = async (req, res) => {
try {
const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true';
const remove = String(req.query.remove || '').toLowerCase() === 'true';
const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove });
res.json({ dryRun, remove, ...summary });
res.json(await details.syncAttachmentManifests({ dryRun: String(req.query.dryRun || '').toLowerCase() === 'true', remove: String(req.query.remove || '').toLowerCase() === 'true' }));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -1635,58 +1037,17 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
const getEventByAlias = async (req, res) => {
const { redirectUrl } = req.params;
if (!VALID_ALIAS.test(redirectUrl)) {
return res.status(404).json({ message: 'Event not found' });
}
try {
const event = await prisma.event.findFirst({
where: {
redirectUrl: {
equals: redirectUrl,
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 res.status(404).json({ message: 'Event not found or inactive' });
}
const now = new Date();
const start = event.startDate ? new Date(event.startDate) : null;
const end = event.endDate ? new Date(event.endDate) : null;
const isLive =
(!start || start >= now) &&
(!end || end >= now);
res.json({ ...event, isLive });
res.json(await details.eventByAlias(req.params.redirectUrl));
} catch (error) {
console.error('[getEventByAlias] Error:', error);
res.status(500).json({ message: 'Internal server error' });
res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Internal server error' });
}
};
module.exports = {
receiveAttachment,
createEvent,
copyEventSetupFrom,
getEvents,
File diff suppressed because it is too large Load Diff
+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 };
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/forgot', loginLimiter, requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
+10 -1
View File
@@ -207,6 +207,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
+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 };
}
+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 },
})),
];
}
+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 })) };
}
File diff suppressed because it is too large Load Diff
+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 } });
}
+2 -1
View File
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log', '/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
+1 -1
View File
@@ -34,7 +34,7 @@ function getBackendUrl() {
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
}
function getOrg() {
+1 -1
View File
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
return base ? `${base}/api/v2/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
+1 -1
View File
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
+1 -1
View File
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
+1 -1
View File
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
return base ? `${base}/api/v2/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
+322 -2
View File
@@ -13,7 +13,31 @@ type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => stri
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
// 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' },
@@ -22,12 +46,308 @@ const SUCCESSORS: Successor[] = [
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
// 1.51.15: privacy and admin
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
// 1.51.14: people and messaging
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
{ method: 'POST', v1: /^\/api\/users\/reset$/, v2: () => '/api/v2/auth/password/reset' },
{ method: 'POST', v1: /^\/api\/users\/activate$/, v2: () => '/api/v2/auth/activate' },
{ method: 'POST', v1: /^\/api\/users\/revoke-sessions$/, v2: () => '/api/v2/me/sessions/revoke' },
{ method: 'POST', v1: /^\/api\/users\/close-account$/, v2: () => '/api/v2/me/close' },
{ method: 'GET', v1: /^\/api\/users\/activity$/, v2: () => '/api/v2/me/activity' },
{ method: 'POST', v1: /^\/api\/users\/verify-password$/, v2: () => '/api/v2/me/verify-password' },
{ method: 'POST', v1: /^\/api\/users\/2fa\/(enable|confirm|disable)$/, v2: m => `/api/v2/me/two-factor/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/check-exists$/, v2: () => '/api/v2/users/exists' },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/revoke-sessions$/, v2: m => `/api/v2/users/${m[1]}/sessions/revoke` },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/anonymize$/, v2: m => `/api/v2/users/${m[1]}/anonymize` },
{ method: 'GET', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/users' },
// (/api/users/profile is /api/v2/me — see 1.51.2 below.)
{ method: 'GET', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/users\/([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/admin\/all$/, v2: () => '/api/v2/admin/events' },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees\/schedule$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees/schedule` },
{ method: 'POST', v1: /^\/api\/broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/email/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp-broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scheduled-emails\/?$/, v2: () => '/api/v2/scheduled-messages' },
{ method: 'PATCH', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'POST', v1: /^\/api\/automations\/schedule$/, v2: () => '/api/v2/automations/schedule' },
{ method: 'GET', v1: /^\/api\/whatsapp\/(config|status|qr)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/config$/, v2: () => '/api/v2/whatsapp/config' },
{ method: 'POST', v1: /^\/api\/whatsapp\/create-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/delete-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/(request-code|logout|start|restart)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'POST', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'GET', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'POST', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'PUT', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'GET', v1: /^\/api\/dashboard\/(today|search)$/, v2: m => `/api/v2/dashboard/${m[1]}` },
{ method: 'GET', v1: /^\/api\/stats\/(staff|supervisor|admin|overview)$/, v2: m => `/api/v2/stats/${m[1]}` },
{ method: 'POST', v1: /^\/api\/analytics\/track$/, v2: () => '/api/v2/analytics/track' },
{ method: 'GET', v1: /^\/api\/analytics\/funnel$/, v2: () => '/api/v2/analytics/funnel' },
{ method: 'POST', v1: /^\/api\/reports\/(pdf|excel|email|whatsapp)$/, v2: m => `/api/v2/reports/${m[1]}` },
// 1.51.13: kids check-in and volunteer rosters
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'PUT', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'DELETE', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me\/(children|adults)$/, v2: m => `/api/v2/me/family/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/setup-events$/, v2: () => '/api/v2/kids/setup-events' },
{ method: 'GET', v1: /^\/api\/kids\/events$/, v2: () => '/api/v2/kids/events' },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/(settings|board|families|offline)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/kids/settings` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/(families|check-in|check-out|duty|sync)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/qr\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/qr/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/(parents|children|adults)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/${m[3]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/children\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/children/${m[3]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/code\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/code/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/check-ins\/([^/]+)\/(override|page)$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/check-ins\/([^/]+)\/room$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/room` },
{ method: 'POST', v1: /^\/api\/kids\/alerts\/([^/]+)\/resolve$/, v2: m => `/api/v2/kids/alerts/${m[1]}/resolve` },
{ method: 'GET', v1: /^\/api\/volunteers\/me$/, v2: () => '/api/v2/me/volunteering' },
{ method: 'GET', v1: /^\/api\/volunteers\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'POST', v1: /^\/api\/volunteers\/roles\/([^/]+)\/sign-up$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/sign-up` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/slots\/([^/]+)\/swap$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/swap` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/swaps\/([^/]+)$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/swaps\/([^/]+)\/accept$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}/accept` },
{ method: 'POST', v1: /^\/api\/volunteers\/check-in$/, v2: () => '/api/v2/volunteering/check-in' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events$/, v2: () => '/api/v2/volunteering/events' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/hours$/, v2: () => '/api/v2/volunteering/hours' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteers` },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/(qr|settings)$/, v2: m => `/api/v2/events/${m[1]}/volunteers/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/volunteers/settings` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/roles$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)\/assign$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/assign` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)\/check-in$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/check-in` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/remove` },
// 1.51.12: tickets, scan locks, attendance and certificates, allocations
{ method: 'GET', v1: /^\/api\/tickets\/mytickets$/, v2: () => '/api/v2/me/tickets' },
{ method: 'POST', v1: /^\/api\/tickets\/email$/, v2: () => '/api/v2/me/tickets/email' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/recent$/, v2: () => '/api/v2/tickets/scans/recent' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/stats$/, v2: () => '/api/v2/tickets/scans/stats' },
{ method: 'POST', v1: /^\/api\/tickets\/send-to$/, v2: () => '/api/v2/tickets/send' },
{ method: 'POST', v1: /^\/api\/tickets\/generate$/, v2: () => '/api/v2/tickets/generate' },
{ method: 'PUT', v1: /^\/api\/tickets\/email-sent$/, v2: () => '/api/v2/tickets/email-sent' },
{ method: 'GET', v1: /^\/api\/tickets\/qr\/([^/]+)$/, v2: m => `/api/v2/tickets/qr/${m[1]}` },
{ method: 'GET', v1: /^\/api\/tickets\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/tickets` },
{ method: 'GET', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)\/(accept|decline)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/tickets\/?$/, v2: () => '/api/v2/tickets' },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)\/transfers$/, v2: m => `/api/v2/tickets/${m[1]}/transfers` },
{ method: 'POST', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'DELETE', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)$/, v2: m => `/api/v2/tickets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'POST', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'GET', v1: /^\/api\/scan-locks\/reports$/, v2: () => '/api/v2/scan-locks/reports' },
{ method: 'POST', v1: /^\/api\/scan-locks\/reports\/([^/]+)\/review$/, v2: m => `/api/v2/scan-locks/reports/${m[1]}/review` },
{ method: 'GET', v1: /^\/api\/scan-locks\/([^/]+)\/snapshot$/, v2: m => `/api/v2/scan-locks/${m[1]}/snapshot` },
{ method: 'POST', v1: /^\/api\/scan-locks\/([^/]+)\/(sync|force-release)$/, v2: m => `/api/v2/scan-locks/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/scan-locks\/([^/]+)$/, v2: m => `/api/v2/scan-locks/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/verify\/([^/]+)$/, v2: m => `/api/v2/certificates/verify/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/mine$/, v2: () => '/api/v2/me/certificates' },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/preview$/, v2: () => '/api/v2/certificates/preview' },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/([^/]+)\/pdf$/, v2: m => `/api/v2/certificates/${m[1]}/pdf` },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/([^/]+)\/revoke$/, v2: m => `/api/v2/certificates/${m[1]}/revoke` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/export$/, v2: m => `/api/v2/events/${m[1]}/attendance/export` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/mark$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates$/, v2: m => `/api/v2/events/${m[1]}/certificates` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates\/send$/, v2: m => `/api/v2/events/${m[1]}/certificates/send` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/certificates\.pdf$/, v2: m => `/api/v2/events/${m[1]}/certificates.pdf` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'GET', v1: /^\/api\/allocations\/my-groups$/, v2: () => '/api/v2/me/allocation-groups' },
{ method: 'GET', v1: /^\/api\/allocations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/allocations` },
{ method: 'POST', v1: /^\/api\/allocations\/sets$/, v2: () => '/api/v2/allocation-sets' },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/allocations\/sets\/([^/]+)\/lists$/, v2: m => `/api/v2/allocation-sets/${m[1]}/lists` },
{ method: 'POST', v1: /^\/api\/allocations\/sets\/([^/]+)\/(groups|auto)$/, v2: m => `/api/v2/allocation-sets/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'PUT', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
// 1.51.10: managing registrations
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
// 1.51.9: series, season passes, bundles, coupons, campuses
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
{ method: 'GET', v1: /^\/api\/series\/by-slug\/([^/]+)$/, v2: m => `/api/v2/series/by-slug/${m[1]}` },
{ method: 'GET', v1: /^\/api\/series\/([^/]+)\/compare$/, v2: m => `/api/v2/series/${m[1]}/compare` },
{ method: 'POST', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
{ method: 'PUT', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
{ method: 'GET', v1: /^\/api\/season-passes\/mine$/, v2: () => '/api/v2/me/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/purchases\/([^/]+)\/checkout$/, v2: m => `/api/v2/season-pass-purchases/${m[1]}/checkout` },
{ method: 'POST', v1: /^\/api\/season-passes\/([^/]+)\/buy$/, v2: m => `/api/v2/season-passes/${m[1]}/buy` },
{ method: 'GET', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'POST', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
{ method: 'PUT', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
{ method: 'POST', v1: /^\/api\/bundles\/quote$/, v2: () => '/api/v2/bundles/quote' },
{ method: 'GET', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'POST', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
{ method: 'PUT', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/coupons\/validate$/, v2: () => '/api/v2/coupons/check' },
{ method: 'GET', v1: /^\/api\/coupons\/redemptions$/, v2: () => '/api/v2/coupons/redemptions' },
{ method: 'GET', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'POST', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
{ method: 'GET', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
{ method: 'GET', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/admin$/, v2: () => '/api/v2/campuses/admin' },
{ method: 'GET', v1: /^\/api\/campuses\/by-slug\/([^/]+)$/, v2: m => `/api/v2/campuses/by-slug/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/campuses\/settings$/, v2: () => '/api/v2/campuses/enabled' },
{ method: 'PUT', v1: /^\/api\/campuses\/order$/, v2: () => '/api/v2/campuses/order' },
{ method: 'POST', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
{ method: 'GET', v1: /^\/api\/campuses\/([^/]+)\/audience$/, v2: m => `/api/v2/campuses/${m[1]}/audience` },
{ method: 'PUT', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
+9 -2
View File
@@ -31,6 +31,8 @@ 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);
@@ -69,14 +71,19 @@ export function toApiError(err: unknown, responseStatus: number): ApiError {
// utils throw errors with statusCode. Those messages are written for people already.
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
const code = STATUS_CODE[status];
if (code) return new ApiError(code, e.message || 'Request failed.');
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 } : {}) } });
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.'));
+10 -1
View File
@@ -7,9 +7,18 @@ 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/money';
import './resources/attendance';
import './resources/kidsVolunteers';
import './resources/peopleMessaging';
import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
@@ -82,7 +91,7 @@ const DOCS_HTML = `<!doctype html>
sessionStorage.setItem('v2docs_token', t);
if (params.has('token')) history.replaceState(null, '', location.pathname);
document.getElementById('login').hidden = true;
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
ui.preauthorizeApiKey('bearerAuth', t);
}).catch(function (e) {
document.getElementById('login').hidden = false;
+52
View File
@@ -0,0 +1,52 @@
/**
* Incoming webhooks and the links in sent messages on v2 (1.51.16). These aren't JSON API calls,
* so they're plain Express routes, mounted by app.js beside the v2 router:
*
* - `webhooksRouter` at /api/v2/webhooks: Yoco, PayFast and WAWP (WhatsApp) post here. New
* provider registrations use these URLs; the v1 paths (/api/webhooks/…, /api/whatsapp/webhook)
* are still answered by the same handlers, because providers may not follow a redirect on POST.
* - `linksRouter` at /api/v2: the one-click unsubscribe link (RFC 8058) from email headers.
* - `legacyLinkRedirects` at /api: links in messages already sent (calendar files, opening a
* one-click unsubscribe link) answer 308 with their v2 address. A mail app's one-click POST to
* the v1 address is still handled there, like a webhook.
*/
import express, { Router, type RequestHandler } from 'express';
/* eslint-disable @typescript-eslint/no-explicit-any -- the handlers are JavaScript */
const { handleYocoWebhook } = require('../controllers/webhookController') as { handleYocoWebhook: RequestHandler };
const { handleProviderWebhook } = require('../payments/webhook') as { handleProviderWebhook(id: string): RequestHandler };
const { handleWebhook: handleWhatsappWebhook } = require('../controllers/whatsappController') as { handleWebhook: RequestHandler };
const privacy = require('../controllers/privacyController') as { oneClickRedirect: RequestHandler; oneClickUnsubscribe: RequestHandler };
/** Paths whose request body is read raw (signatures are checked over the exact bytes). */
export const RAW_BODY_PREFIXES = ['/api/webhooks', '/api/v2/webhooks'];
/** WAWP sends JSON (and ?secret=…); on the webhook paths its body arrives raw. */
export const whatsappWebhook: RequestHandler[] = [(req: any, _res, next) => {
try { req.body = req.rawBody ? JSON.parse(req.rawBody) : {}; } catch { req.body = {}; }
next();
}, handleWhatsappWebhook];
export const webhooksRouter = Router();
webhooksRouter.post('/yoco', handleYocoWebhook);
webhooksRouter.post('/payfast', handleProviderWebhook('payfast'));
webhooksRouter.post('/whatsapp', ...whatsappWebhook);
export const linksRouter = Router();
linksRouter.get('/unsubscribe/:token/one-click', privacy.oneClickRedirect);
linksRouter.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** v1 links in messages already sent → their v2 address (308 keeps the method). */
const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string }[] = [
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
];
export const legacyLinkRedirects: RequestHandler = (req, res, next) => {
const [path, query] = req.originalUrl.split('?');
for (const r of REDIRECTS) {
const m = req.method === r.method ? path.match(r.v1) : null;
if (m) return res.redirect(308, r.v2(m) + (query ? `?${query}` : ''));
}
next();
};
+6 -2
View File
@@ -71,9 +71,13 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
parameters: parameters(def),
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
...(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)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
[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)') } : {}),
+126
View File
@@ -0,0 +1,126 @@
/**
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
* access checks as v1.
*/
import { z } from 'zod';
import { idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
const audit = require('../../controllers/adminAuditController');
const imp = require('../../controllers/impersonationController');
const jobs = require('../../controllers/jobsController');
const settings = require('../../controllers/settingsController');
const hub = require('../../controllers/hubController');
const hooks = require('../../controllers/outboundWebhookController');
const backups = require('../../controllers/backupController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const tokenParams = z.object({ token: z.string().min(1).max(500) });
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// ─── My privacy ──────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
// ─── Privacy admin ───────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
// ─── Admin ───────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
// ─── Settings and first-run set-up ───────────────────────────────────────────
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
// ─── Hub ─────────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
v1Route({
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
});
// ─── Outbound webhooks ───────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
// ─── Backups ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
const yoco = require('../../controllers/yocoController');
const payfast = require('../../controllers/payfastController');
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
+195
View File
@@ -0,0 +1,195 @@
/**
* Tickets and attendance on v2 (1.51.12): tickets (mine, one, sending, transfers, the staff
* lists, generating), scan locks (offline scanning), the attendance register and certificates,
* and allocations (groups, rooms, teams). Each route runs the v1 handler (viaV1.ts) behind the
* same access and campus checks as v1. Scanning itself is /checkin (1.51.2).
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean; nameCampus?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
campusIndependent(reason: string): any;
};
const t = require('../../controllers/ticketController');
const locks = require('../../controllers/scanLockController');
const att = require('../../controllers/attendanceController');
const alloc = require('../../controllers/allocationController');
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const loose = z.looseObject({});
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const ticketOwner = cs.scopeEvent({ resolve: 'ticket', ownerOk: true });
const qrScope = cs.scopeEvent({ resolve: 'ticketQr', from: (req: any) => req.params.qrCode, nameCampus: true });
// ─── Tickets ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', v1: t.getUserTickets });
v1Route({
method: 'post', path: '/me/tickets/email', tag: 'Tickets', summary: 'Send me my tickets',
description: 'For a registration (`registrationId`) or chosen tickets (`ticketIds`), by email and/or WhatsApp (`channel`).',
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), v1: t.emailTickets,
});
v1Route({
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), v1: t.getRecentScans,
});
v1Route({
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), v1: t.getScanStats,
});
v1Route({
method: 'post', path: '/tickets/send', tag: 'Tickets', summary: 'Send tickets to someone',
description: 'Staff: a registration\'s tickets (or chosen ones) to an email address or phone number.',
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, v1: t.sendTicketsTo,
});
v1Route({
method: 'post', path: '/tickets/generate', tag: 'Tickets', summary: 'Issue a registration\'s tickets',
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), v1: t.generateTickets,
});
v1Route({
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, v1: t.markTicketsAsEmailSent,
});
v1Route({
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), v1: t.getTickets,
});
v1Route({
method: 'get', path: '/tickets/qr/:qrCode', tag: 'Tickets', summary: 'The ticket for a QR code',
description: 'Without checking it in (that\'s POST /checkin/{qrCode}).',
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), v1: t.getTicketByQrCode,
});
v1Route({
method: 'get', path: '/events/:id/tickets', tag: 'Tickets', summary: 'An event\'s tickets',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: t.getTicketsByEvent,
});
v1Route({
method: 'get', path: '/ticket-transfers/:token', tag: 'Tickets', summary: 'A ticket someone is giving you',
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, v1: t.getTransferOffer,
});
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, v1: t.acceptTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, v1: t.declineTransferOffer });
v1Route({
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketById,
});
v1Route({
method: 'post', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Give a ticket to someone',
description: 'Sends them a link to accept it; the ticket is theirs once they do.',
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, v1: t.transferTicketHandler,
});
v1Route({
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.cancelTransferHandler,
});
v1Route({
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketTransfers,
});
// ─── Scan locks (offline scanning) ───────────────────────────────────────────
v1Route({
method: 'get', path: '/scan-locks', tag: 'Check-in', summary: 'Devices scanning offline for an event',
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), v1: locks.listLocks,
});
v1Route({
method: 'post', path: '/scan-locks', tag: 'Check-in', summary: 'Take tickets offline on this device',
description: 'Locks the chosen tickets to this device until it syncs or releases them.',
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: locks.createLock,
});
v1Route({
method: 'get', path: '/scan-locks/reports', tag: 'Check-in', summary: 'Offline sync reports to review',
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), v1: locks.listReports,
});
v1Route({
method: 'post', path: '/scan-locks/reports/:id/review', tag: 'Check-in', summary: 'Mark a sync report reviewed',
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, v1: locks.reviewReport,
});
v1Route({
method: 'get', path: '/scan-locks/:id/snapshot', tag: 'Check-in', summary: 'The tickets a device took offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), v1: locks.getSnapshot,
});
v1Route({
method: 'post', path: '/scan-locks/:id/sync', tag: 'Check-in', summary: 'Upload scans made offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.syncLock,
});
v1Route({
method: 'post', path: '/scan-locks/:id/force-release', tag: 'Check-in', summary: 'Release a lost device\'s tickets',
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.forceRelease,
});
v1Route({
method: 'delete', path: '/scan-locks/:id', tag: 'Check-in', summary: 'Release tickets back online',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), v1: locks.releaseLock,
});
// ─── Attendance and certificates ─────────────────────────────────────────────
v1Route({
method: 'get', path: '/certificates/verify/:code', tag: 'Attendance', summary: 'Check a certificate is genuine',
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), v1: att.verifyCertificate,
});
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', v1: att.myCertificates });
v1Route({
method: 'post', path: '/certificates/preview', tag: 'Attendance', summary: 'Preview a certificate template',
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, v1: att.previewCertificate,
});
v1Route({
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
description: 'Its owner, or staff.', access: 'user', params: idParams, file: { contentType: 'application/pdf' }, v1: att.downloadCertificate,
});
v1Route({
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, v1: att.revokeCertificate,
});
v1Route({
method: 'get', path: '/events/:id/attendance', tag: 'Attendance', summary: 'An event\'s attendance register',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: att.getRegister,
});
v1Route({
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
description: '`format=pdf` or `xlsx`.', access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('format', 'sessionId'),
file: { contentType: 'application/pdf' }, map: asEvent, v1: att.exportRegister,
});
v1Route({
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.markAttendance,
});
v1Route({
method: 'post', path: '/events/:id/certificates', tag: 'Attendance', summary: 'Issue certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.issueCertificates,
});
v1Route({
method: 'post', path: '/events/:id/certificates/send', tag: 'Attendance', summary: 'Send certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.sendCertificatesHandler,
});
v1Route({
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, file: { contentType: 'application/pdf' }, map: asEvent, v1: att.downloadEventCertificates,
});
// ─── Allocations (groups, rooms, teams) ──────────────────────────────────────
v1Route({
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], v1: alloc.getMyGroups,
});
v1Route({
method: 'get', path: '/events/:id/allocations', tag: 'Allocations', summary: 'An event\'s allocation board',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: alloc.getBoard,
});
v1Route({
method: 'post', path: '/allocation-sets', tag: 'Allocations', summary: 'Start a set of groups',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: alloc.createSet,
});
const setScope = cs.scopeEvent({ resolve: 'allocationSet' });
const groupScope = cs.scopeEvent({ resolve: 'allocationGroup' });
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.updateSet });
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.deleteSet });
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), v1: alloc.getSetLists });
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, v1: alloc.createGroup });
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.assign });
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.clearAssignments });
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.autoAssign });
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, v1: alloc.updateGroup });
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, v1: alloc.deleteGroup });
+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)) };
},
});
@@ -0,0 +1,92 @@
/**
* Kids check-in and volunteer rosters on v2 (1.51.13). Each route runs the v1 handler
* (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeList: any;
};
const kids = require('../../controllers/kidsController');
const vol = require('../../controllers/volunteerController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
/** v2 `:id` is the event; v1 called it `:eventId` (other params keep their names). */
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const view = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
const manage = { campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })] };
// ─── Kids: my family ─────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/family', tag: 'Kids', summary: 'My family', description: 'Children, the adults who may collect them, and the family QR code.', access: 'user', v1: kids.getMine });
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, v1: kids.createMine });
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, v1: kids.renameMine });
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', v1: kids.leaveMine });
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, v1: kids.saveMyChild });
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, v1: kids.saveMyChild });
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, v1: kids.deleteMyChild });
v1Route({ method: 'post', path: '/me/family/adults', tag: 'Kids', summary: 'Add an adult who may collect', access: 'user', status: 201, body: loose, v1: kids.saveMyAdult });
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, v1: kids.saveMyAdult });
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, v1: kids.deleteMyAdult });
// ─── Kids: set-up and the check-in desk ──────────────────────────────────────
v1Route({ method: 'get', path: '/kids/setup-events', tag: 'Kids', summary: 'Events that can have kids check-in', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.setupEvents });
v1Route({ method: 'get', path: '/kids/events', tag: 'Kids', summary: 'Events with kids check-in', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.listEvents });
v1Route({ method: 'get', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'An event\'s kids check-in set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: kids.getSettings });
v1Route({ method: 'put', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'Set up kids check-in', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: kids.saveSettings });
v1Route({ method: 'get', path: '/events/:id/kids/board', tag: 'Kids', summary: 'Who is checked in, by room', access: 'staff', ...view, params: idParams, query: filters('sessionId', 'log'), map: asEvent, v1: kids.getBoard });
v1Route({ method: 'get', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Find a family', access: 'staff', ...view, params: idParams, query: filters('q', 'sessionId'), map: asEvent, v1: kids.searchFamilies });
v1Route({ method: 'post', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Register a family at the desk', access: 'staff', ...view, status: 201, params: idParams, body: loose, map: asEvent, v1: kids.createFamilyAtDesk });
v1Route({ method: 'get', path: '/events/:id/kids/families/qr/:token', tag: 'Kids', summary: 'The family for a QR code', access: 'staff', ...view, params: z.object({ id, token: z.string().min(1).max(200) }), map: asEvent, v1: kids.familyByQr });
const familyParams = z.object({ id, familyId: id });
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, v1: kids.getFamily });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/parents', tag: 'Kids', summary: 'Add a parent at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addParentAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/children', tag: 'Kids', summary: 'Add a child at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.saveChildAtDesk });
v1Route({ method: 'put', path: '/events/:id/kids/families/:familyId/children/:childId', tag: 'Kids', summary: 'Change a child at the desk', access: 'staff', ...view, params: z.object({ id, familyId: id, childId: id }), body: loose, map: asEvent, v1: kids.saveChildAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/adults', tag: 'Kids', summary: 'Add an adult at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addAdultAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/check-in', tag: 'Kids', summary: 'Check children in', description: 'Answers the collection code and labels to print.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkIn });
v1Route({ method: 'post', path: '/events/:id/kids/check-out', tag: 'Kids', summary: 'Check children out', description: 'With the collection code; refused for someone not allowed to collect.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkOut });
v1Route({ method: 'get', path: '/events/:id/kids/code/:code', tag: 'Kids', summary: 'Who a collection code is for', access: 'staff', ...view, params: z.object({ id, code: z.string().min(1).max(40) }), query: filters('sessionId'), map: asEvent, v1: kids.lookupCode });
v1Route({ method: 'post', path: '/events/:id/kids/duty', tag: 'Kids', summary: 'Say which room I\'m on', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.setDuty });
v1Route({ method: 'get', path: '/events/:id/kids/offline', tag: 'Kids', summary: 'Everything the desk needs offline', access: 'staff', ...view, params: idParams, query: filters('deviceId', 'sessionId'), map: asEvent, v1: kids.offlineSnapshot });
v1Route({ method: 'post', path: '/events/:id/kids/sync', tag: 'Kids', summary: 'Upload check-ins made offline', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.syncOffline });
const checkInScope = { campusScope: 'scopeEvent(kidsCheckIn)', middleware: [cs.scopeEvent({ resolve: 'kidsCheckIn' })] };
v1Route({ method: 'post', path: '/kids/check-ins/:id/override', tag: 'Kids', summary: 'Let someone not on the list collect', description: 'Logged, with the reason.', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.overrideCheckOut });
v1Route({ method: 'post', path: '/kids/check-ins/:id/page', tag: 'Kids', summary: 'Page a child\'s parent', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.pageParent });
v1Route({ method: 'put', path: '/kids/check-ins/:id/room', tag: 'Kids', summary: 'Move a child to another room', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.moveRoom });
v1Route({ method: 'post', path: '/kids/alerts/:id/resolve', tag: 'Kids', summary: 'Resolve an alert', access: 'staff', campusScope: 'scopeEvent(kidsAlert)', middleware: [cs.scopeEvent({ resolve: 'kidsAlert' })], params: idParams, body: loose, v1: kids.resolveAlert });
// ─── Volunteering: mine ──────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', v1: vol.getMine });
v1Route({ method: 'get', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Roles I can serve in at an event', access: 'user', params: idParams, map: asEvent, v1: vol.getEventRoles });
v1Route({ method: 'post', path: '/volunteer-roles/:id/sign-up', tag: 'Volunteers', summary: 'Sign up to serve', access: 'user', status: 201, params: idParams, body: loose, v1: vol.signUp });
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, v1: vol.withdraw });
v1Route({ method: 'post', path: '/volunteer-slots/:id/swap', tag: 'Volunteers', summary: 'Ask someone to take my slot', access: 'user', params: idParams, body: loose, v1: vol.requestSwap });
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, v1: vol.cancelSwap });
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, v1: vol.acceptSwap });
v1Route({ method: 'post', path: '/volunteering/check-in', tag: 'Volunteers', summary: 'Check myself in (the event\'s QR code)', access: 'user', body: loose, v1: vol.checkIn });
// ─── Volunteering: rosters ───────────────────────────────────────────────────
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), v1: vol.listEvents });
v1Route({ method: 'get', path: '/volunteering/hours', tag: 'Volunteers', summary: 'Hours served', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'eventId'), v1: vol.hours });
v1Route({ method: 'get', path: '/events/:id/volunteers', tag: 'Volunteers', summary: 'An event\'s roster', access: 'staff', ...view, params: idParams, query: filters('log'), map: asEvent, v1: vol.getRoster });
v1Route({ method: 'get', path: '/events/:id/volunteers/qr', tag: 'Volunteers', summary: 'The self check-in QR code', access: 'staff', ...view, params: idParams, map: asEvent, v1: vol.getQr });
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: vol.getSettings });
v1Route({ method: 'put', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Set up volunteering', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: vol.saveSettings });
v1Route({ method: 'post', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Add a role', access: 'supervisor', ...manage, status: 201, params: idParams, body: loose, map: asEvent, v1: vol.createRole });
const roleManage = { campusScope: 'scopeEvent(servingRole, manage)', middleware: [cs.scopeEvent({ resolve: 'servingRole', need: 'manage' })] };
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, v1: vol.updateRole });
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, v1: vol.deleteRole });
v1Route({ method: 'post', path: '/volunteer-roles/:id/assign', tag: 'Volunteers', summary: 'Put someone in a role', access: 'supervisor', campusScope: 'scopeEvent(servingRole)', middleware: [cs.scopeEvent({ resolve: 'servingRole' })], params: idParams, body: loose, v1: vol.assign });
const slotScope = { campusScope: 'scopeEvent(volunteerSlot)', middleware: [cs.scopeEvent({ resolve: 'volunteerSlot' })] };
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, v1: vol.checkInManual });
v1Route({ method: 'post', path: '/volunteer-slots/:id/remove', tag: 'Volunteers', summary: 'Take someone off a slot', access: 'supervisor', ...slotScope, params: idParams, body: loose, v1: vol.remove });
+24 -4
View File
@@ -25,20 +25,40 @@ const toMe = (u: MeRow): z.input<typeof me> => ({
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { requiresTwoFactor } = require('../../utils/twoFactor') as { requiresTwoFactor(user: object): boolean };
const privacyNotice = require('../../utils/privacyNotice') as { noticePromptPending(u: { noticeVersionAccepted: unknown }): Promise<false | 'first' | 'updated'> };
const campusScope = require('../../utils/campusScope') as { scopeFor(req: unknown): Promise<{ campusIds: string[] | null } | null> };
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
description: 'With what the app asks after sign-in (`newsPromptPending`, `noticePromptPending`), whether two-step sign-in is required for their role, and the campuses they\'re limited to (`campusScope`, null = every campus). Viewing as someone else never prompts.',
access: 'user',
response: single(me),
async handler({ user }) {
response: single(me.extend({
twoFactorRequired: z.boolean().describe('Their role must use two-step sign-in'),
newsPromptPending: z.boolean(),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]),
campusScope: z.array(id).nullable(),
})),
async handler({ user, req }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: toMe(u) };
const impersonating = !!(req as any).impersonation;
return {
data: {
...toMe(u),
twoFactorRequired: requiresTwoFactor(u),
newsPromptPending: !u.newsAskedAt && !impersonating,
noticePromptPending: impersonating ? false as const : await privacyNotice.noticePromptPending({ noticeVersionAccepted: u.noticeVersionAccepted }),
campusScope: (await campusScope.scopeFor(req))?.campusIds ?? null,
},
};
},
});
+310
View File
@@ -0,0 +1,310 @@
/**
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { listData, v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
unscopedOnly: any;
};
const pay = require('../../controllers/paymentController');
const providers = require('../../controllers/paymentProvidersController');
const cashup = require('../../controllers/cashupController');
const ptx = require('../../controllers/providerTransactionsController');
const waitlist = require('../../controllers/waitlistController');
const sponsor = require('../../controllers/sponsorshipController');
const consent = require('../../controllers/consentController');
const forms = require('../../controllers/formController');
const feedback = require('../../controllers/feedbackController');
/** Optional text filters, passed to v1 as they come. */
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
// Links in messages are credentials, so guessing stays slow (as on v1).
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
// ─── Payments ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
access: 'public', v1: providers.listPaymentProviders,
});
v1Route({
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
});
v1Route({
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
});
v1Route({
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
});
v1Route({
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
description: 'Records any that were paid (for when a webhook was missed).',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
});
v1Route({
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
});
v1Route({
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
});
v1Route({
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
v1: pay.createPayment,
});
v1Route({
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
});
v1Route({
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
});
v1Route({
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
});
v1Route({
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
});
v1Route({
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
});
v1Route({
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
});
v1Route({
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
});
v1Route({
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
description: 'By email and/or WhatsApp, for what\'s still owing.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
});
v1Route({
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
});
v1Route({
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
access: 'public', params: idParams, v1: providers.getPayPage,
});
v1Route({
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
});
// ─── Cash-ups ────────────────────────────────────────────────────────────────
const cashupBase = '/events/:id/cashup';
v1Route({
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
});
v1Route({
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
});
v1Route({
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
});
v1Route({
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
});
v1Route({
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
});
v1Route({
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
});
v1Route({
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
});
v1Route({
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
});
// ─── Provider transactions ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
});
v1Route({
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
});
// ─── Waitlist ────────────────────────────────────────────────────────────────
v1Route({
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
});
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
v1Route({
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
});
v1Route({
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
});
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
v1Route({
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
});
}
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
// ─── Sponsorship ─────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
});
v1Route({
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
});
v1Route({
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
});
// ─── Consent ─────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
});
v1Route({
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
});
v1Route({
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
});
v1Route({
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
});
v1Route({
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
body: z.looseObject({}), v1: consent.resendRequest,
});
v1Route({
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
});
v1Route({
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
});
// ─── Attendee form answers ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
});
// ─── Feedback ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
});
v1Route({
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
});
v1Route({
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
});
v1Route({
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
});
v1Route({
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
});
v1Route({
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
});
+138
View File
@@ -0,0 +1,138 @@
/**
* People and messaging on v2 (1.51.14): the rest of accounts (password reset, activation,
* two-step sign-in, sessions, closing an account), messaging attendees, email and WhatsApp
* broadcasts, scheduled messages, automations, the site banner, saved filters, the dashboards,
* stats, analytics and report files. Each route runs the v1 handler (viaV1.ts) behind the same
* access and campus checks as v1.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { loginLimiter } = require('../../middleware/authMiddleware') as { loginLimiter: any };
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; from?: (req: any) => unknown }): any;
scopeBroadcast(opts: { freeformField: string }): any;
scopeList: any;
unscopedOnly: any;
campusIndependent(reason: string): any;
};
const users = require('../../controllers/userController');
const events = require('../../controllers/eventController');
const wa = require('../../controllers/whatsappController');
const bc = require('../../controllers/broadcastController');
const wbc = require('../../controllers/whatsappBroadcastController');
const sched = require('../../controllers/scheduledEmailsController');
const auto = require('../../controllers/automationController');
const banner = require('../../controllers/bannerController');
const stats = require('../../controllers/statsController');
const analytics = require('../../controllers/analyticsController');
const reports = require('../../controllers/reportController');
// These two keep their handlers in the route file; v2 runs the v1 router with the v1 path.
const savedFilterRouter = require('../../routes/savedFilterRoutes');
const dashboardRouter = require('../../routes/dashboardRoutes');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
/** The query string of the v2 request, for a v1 router's URL. */
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, v1: users.disableTwoFactor });
v1Route({
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('email', 'phone', 'phoneNumber'), v1: users.checkUserExists,
});
v1Route({
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), v1: users.getUsers,
});
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, v1: users.anonymizeUser });
// ─── Messaging an event's people ─────────────────────────────────────────────
const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
v1Route({
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
access: 'public', query: filters('campus', 'series'), v1: events.getEvents,
});
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleWhatsappEventAttendees });
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, v1: schedule });
}
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, v1: sched.deleteScheduledEmail });
v1Route({
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), v1: auto.scheduleAutomations,
});
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', v1: wa.getQrHandler });
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
v1Route({ method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose, v1: handler });
}
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, v1: banner.setBanner });
const OWN = 'campusIndependent (a person\'s own saved filters)';
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), v1: stats.getOverviewStats });
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, v1: reports.whatsappPdf });
@@ -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'),
});
+21
View File
@@ -50,6 +50,13 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
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>;
};
@@ -71,6 +78,10 @@ export function defineRoute<P extends Schema | undefined, Q extends Schema | und
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`);
}
// Two definitions of one route: the first would silently win.
if (routes.some(r => r.method === def.method && r.path === def.path)) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path} is defined twice`);
}
routes.push(def as AnyRoute);
return def;
}
@@ -97,6 +108,13 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
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 | Buffer } }).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);
@@ -105,3 +123,6 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
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.union([z.string(), z.instanceof(Buffer)]) }) });
+185
View File
@@ -0,0 +1,185 @@
/**
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
*
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
* v1's `code` becomes `reason`.
*
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
* 1.51.10 did) can follow later without changing the v2 API.
*/
import { readFile } from 'fs/promises';
import { basename } from 'path';
import { Writable } from 'stream';
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
/** `url` (with its query string) is for v1 routers, which route by it. */
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; url?: string };
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
class RecordingResponse extends Writable {
statusCode = 200;
headersSent = false;
locals: Record<string, unknown> = {};
readonly headers: Record<string, string> = {};
private chunks: Buffer[] = [];
json?: (v: unknown) => this;
answer?: V1Answer;
private done!: (a: V1Answer) => void;
readonly finished: Promise<V1Answer>;
constructor() {
super();
this.finished = new Promise(resolve => { this.done = resolve; });
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
}
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
override _final(cb: () => void) { this.finishWith({}); cb(); }
private finishWith(extra: Partial<V1Answer>): this {
if (this.answer) return this;
this.headersSent = true;
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
this.done(this.answer);
return this;
}
status(code: number) { this.statusCode = code; return this; }
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
set(name: string | Record<string, unknown>, value?: unknown) {
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
else this.setHeader(name, value);
return this;
}
header(name: string, value?: unknown) { return this.set(name, value); }
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
send(body?: unknown) {
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
return this.finishWith({});
}
// A handler that ends without writing anything still answers.
override end(...args: any[]) {
const [chunk] = args;
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
this.finishWith({});
return this;
}
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
const name = typeof filename === 'string' ? filename : basename(filePath);
readFile(filePath).then(buf => {
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
this.setHeader('content-disposition', `attachment; filename="${name}"`);
this.chunks.push(buf);
this.finishWith({});
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
sendFile(filePath: string) {
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
redirect(a: number | string, b?: string) {
const url = typeof a === 'string' ? a : b!;
this.statusCode = typeof a === 'number' ? a : 302;
return this.finishWith({ redirect: url });
}
}
/**
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
* campus scope, read-only state) is the real request's.
*/
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
const fake = Object.create(req) as Request;
for (const key of ['params', 'query', 'body', 'url'] as const) {
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
}
const res = new RecordingResponse();
const chain = Array.isArray(handlers) ? handlers : [handlers];
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
let advanced = false;
const next = (err?: unknown) => {
advanced = true;
if (err) return reject(err);
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
// Nothing answered (e.g. a v1 router with no matching route).
else reject(Object.assign(new Error('There is no such API endpoint.'), { statusCode: 404 }));
};
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
});
await Promise.race([step(0).then(() => res.finished), res.finished]);
return res.answer!;
}
/** A v1 error answer as something v2's error handler understands. */
function v1Error(a: V1Answer): Error {
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
const e = new Error(text) as Error & { statusCode: number; code?: string };
e.statusCode = a.status;
if (typeof j.code === 'string') e.code = j.code;
return e;
}
/** `{ data: <anything> }` — for v1 answers documented by their description. */
export const anyData = z.object({ data: z.unknown() });
export const listData = z.object({ data: z.array(z.looseObject({})) });
export const objectData = z.object({ data: z.looseObject({}) });
type V1RouteDef = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
tag: string;
summary: string;
description?: string;
access: Access;
campusScope?: string;
middleware?: RequestHandler[];
params?: z.ZodType;
query?: z.ZodType;
body?: z.ZodType;
response?: z.ZodType;
status?: number;
file?: { contentType: string };
upload?: { field: string; description: string };
/** The v1 handler (or middleware chain ending in one). */
v1: RequestHandler | RequestHandler[];
/**
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
*/
map?: (ctx: Ctx<any, any, any>) => Parts;
};
/** A v2 route answered by a v1 handler. */
export function v1Route(def: V1RouteDef) {
const { v1, map, response, ...rest } = def;
return defineRoute({
...rest,
response: def.file ? fileResponse : (response ?? anyData),
async handler(ctx) {
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
const a = await runV1(v1, ctx.req, parts);
if (a.status >= 400) throw v1Error(a);
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
if (def.file) {
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
}
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
},
});
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
* the same for every email (before doing any per-account work), and sign-in with an unknown email
* answers exactly like a wrong password.
*/
const mockDb = {
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
import { logIn } from '../src/services/auth';
import { ServiceError } from '../src/services/errors';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { requestPasswordReset } = require('../src/controllers/userController');
type Answer = { status: number; body: unknown };
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
return new Promise(resolve => {
let status = 200;
const res = {
statusCode: 200,
status(n: number) { status = n; this.statusCode = n; return this; },
json(b: unknown) { resolve({ status, body: b }); return this; },
};
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
});
}
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
beforeEach(() => jest.clearAllMocks());
describe('forgot password', () => {
test('answers the same, with the same status, for an email with and without an account', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const known = await call(requestPasswordReset, { email: realUser.email });
expect(unknown).toEqual(known);
expect(known.status).toBe(200);
});
test('only a real account gets a link, made after the answer is sent', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
await call(requestPasswordReset, { email: 'nobody@example.org' });
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const answered = call(requestPasswordReset, { email: realUser.email });
await answered;
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
});
});
describe('sign-in', () => {
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
test('an unknown email is refused exactly like a wrong password', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
});
test('an unknown email still does a password check, so it takes as long', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
+4
View File
@@ -3,6 +3,10 @@ const { allowedWhileImpersonating, impersonationBlocker } = require('../src/util
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
// Ending (and logging) a viewing-as session works on v2 too; other v2 writes stay blocked.
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/log')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/payments')).toBe(false);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import request from 'supertest';
const mockCalls: { name: string; body?: unknown; query?: unknown }[] = [];
jest.mock('../src/controllers/webhookController', () => ({ handleYocoWebhook: (req: { rawBody: string }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'yoco', body: req.rawBody }); res.json({ ok: true }); } }));
jest.mock('../src/payments/webhook', () => ({ handleProviderWebhook: (id: string) => (_req: unknown, res: { json(b: unknown): void }) => { mockCalls.push({ name: id }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/whatsappController', () => ({ handleWebhook: (req: { body: unknown; query: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'whatsapp', body: req.body, query: req.query }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/privacyController', () => ({
oneClickRedirect: (req: { params: { token: string } }, res: { redirect(c: number, u: string): void }) => res.redirect(302, `https://site.example/unsubscribe?t=${req.params.token}`),
oneClickUnsubscribe: (req: { params: { token: string }; body: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'one-click', body: req.body }); res.json({ ok: true }); },
}));
import { webhooksRouter, linksRouter, legacyLinkRedirects } from '../src/v2/links';
// The same order and raw-body handling as app.js.
function app() {
const a = express();
a.use((req, _res, next) => {
if (req.path.startsWith('/api/v2/webhooks')) {
let raw = '';
req.setEncoding('utf8');
req.on('data', (c: string) => { raw += c; });
req.on('end', () => { (req as unknown as { rawBody: string }).rawBody = raw; next(); });
} else next();
});
a.use('/api/v2/webhooks', webhooksRouter);
a.use(express.json());
a.use('/api/v2', linksRouter);
a.use(legacyLinkRedirects);
a.post('/api/privacy/unsubscribe/:token/one-click', (_req, res) => { mockCalls.push({ name: 'v1 one-click' }); res.json({ ok: true }); });
return a;
}
beforeEach(() => { mockCalls.length = 0; });
describe('incoming webhooks on v2', () => {
test('Yoco gets the exact bytes it signed', async () => {
const body = '{"type":"payment.succeeded","payload":{"id":"p1"}}';
await request(app()).post('/api/v2/webhooks/yoco').set('Content-Type', 'application/json').send(body);
expect(mockCalls).toEqual([{ name: 'yoco', body }]);
});
test('PayFast reaches the PayFast handler', async () => {
await request(app()).post('/api/v2/webhooks/payfast').type('form').send('m_payment_id=c1');
expect(mockCalls[0].name).toBe('payfast');
});
test('WhatsApp gets its JSON parsed and keeps the secret in the query', async () => {
await request(app()).post('/api/v2/webhooks/whatsapp?secret=s3').send({ event: 'message', payload: { body: 'hi' } });
expect(mockCalls[0]).toEqual({ name: 'whatsapp', body: { event: 'message', payload: { body: 'hi' } }, query: { secret: 's3' } });
});
});
describe('links in sent messages', () => {
test('an old calendar link answers 308 with its v2 address', async () => {
const res = await request(app()).get('/api/events/e1/ics?x=1');
expect(res.status).toBe(308);
expect(res.headers.location).toBe('/api/v2/events/e1/calendar.ics?x=1');
});
test('opening an old one-click link goes to v2, which sends the person to the page', async () => {
const old = await request(app()).get('/api/privacy/unsubscribe/tok/one-click');
expect(old.status).toBe(308);
expect(old.headers.location).toBe('/api/v2/unsubscribe/tok/one-click');
const v2 = await request(app()).get('/api/v2/unsubscribe/tok/one-click');
expect(v2.status).toBe(302);
expect(v2.headers.location).toBe('https://site.example/unsubscribe?t=tok');
});
test("a mail app's one-click POST works on v2, and on the old address without a redirect", async () => {
await request(app()).post('/api/v2/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(mockCalls[0]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
const old = await request(app()).post('/api/privacy/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(old.status).toBe(200);
expect(mockCalls[1].name).toBe('v1 one-click');
});
});
+1 -1
View File
@@ -193,7 +193,7 @@ describe('PayFast provider', () => {
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
notify_url: 'https://api.example.org/api/v2/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
+1 -1
View File
@@ -97,7 +97,7 @@ describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/v2\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
+264 -1
View File
@@ -49,10 +49,55 @@ jest.mock('../src/utils/campusScope', () => ({
// 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 () => ({}),
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
scopeBroadcast: () => (_req: unknown, _res: unknown, next: () => void) => next(),
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
campusIndependent: () => (_req: unknown, _res: unknown, next: () => void) => next(),
}));
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() }));
jest.mock('../src/controllers/cashupController', () => {
const echo = (req: { params: object; body: object }, res: { json(b: unknown): void }) => res.json({ params: req.params, body: req.body });
return {
getEventCashup: echo, getCashByRecordedUser: echo, savePersonCash: echo, saveEventCashupDraft: echo, reopenEvent: echo, resolvePending: echo, getCashupAudit: echo,
closeEvent: (_req: unknown, res: { status(n: number): { json(b: unknown): void } }) => res.status(400).json({ message: 'There are pending lines.', code: 'PENDING_LINES' }),
};
});
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
import { routes } from '../src/v2/route';
@@ -347,7 +392,9 @@ describe('v1 deprecation headers', () => {
const { successorOf } = require('../src/v2/deprecation');
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('GET', '/api/events/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');
@@ -430,3 +477,219 @@ describe('events (staff)', () => {
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' });
});
});
describe('money (v1 handlers behind v2 routes)', () => {
test("an event's cash-up reaches v1 with the event as :eventId", async () => {
signedIn = { id: 'v1', role: 'supervisor' };
const res = await request(app).get('/api/v2/events/e1/cashup');
expect(res.status).toBe(200);
expect(res.body.data.params).toMatchObject({ eventId: 'e1' });
});
test('closing is for admins, and a v1 refusal keeps its code as the reason', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).post('/api/v2/events/e1/cashup/close').send({})).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).post('/api/v2/events/e1/cashup/close').send({});
expect(res.status).toBe(400);
expect(res.body.error).toEqual({ code: 'VALIDATION_FAILED', message: 'There are pending lines.', reason: 'PENDING_LINES' });
});
test('a cash-up line is given to v1 as :lineId', async () => {
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).post('/api/v2/cashup-lines/l9/pending').send({ resolution: 'paid' });
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
});
});
+100
View File
@@ -0,0 +1,100 @@
import express from 'express';
import request from 'supertest';
import { PassThrough } from 'stream';
import { z } from 'zod';
jest.mock('../src/config/db', () => ({}));
jest.mock('../src/middleware/authMiddleware', () => ({ protect: jest.fn(), optionalAuth: jest.fn(), admin: jest.fn(), staff: jest.fn(), supervisor: jest.fn() }));
import { mount } from '../src/v2/route';
import { errorHandler } from '../src/v2/errors';
import { v1Route } from '../src/v2/viaV1';
import type { RequestHandler } from 'express';
function appWith(...defs: ReturnType<typeof v1Route>[]) {
const app = express();
app.use(express.json());
const router = express.Router();
mount(router, defs);
app.use('/api/v2', router);
app.use('/api/v2', errorHandler);
return app;
}
describe('v2 routes answered by v1 handlers', () => {
test("v1's JSON becomes data; v2's :id is given to v1 by its old name", async () => {
const handler: RequestHandler = (req, res) => { res.json({ eventId: req.params.eventId, q: req.query.x }); };
const app = appWith(v1Route({ method: 'get', path: '/things/:id', tag: 'T', summary: 's', access: 'public', v1: handler, map: ctx => ({ params: { eventId: ctx.params.id } }), params: z.object({ id: z.string() }) }));
const res = await request(app).get('/api/v2/things/e1?x=2');
expect(res.status).toBe(200);
expect(res.body).toEqual({ data: { eventId: 'e1', q: '2' } });
});
test("a v1 error is a v2 error, with v1's code as the reason", async () => {
const handler: RequestHandler = (_req, res) => { res.status(409).json({ message: 'Already closed.', code: 'EVENT_CLOSED' }); };
const app = appWith(v1Route({ method: 'post', path: '/close', tag: 'T', summary: 's', access: 'public', v1: handler }));
const res = await request(app).post('/api/v2/close').send({});
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: { code: 'CONFLICT', message: 'Already closed.', reason: 'EVENT_CLOSED' } });
});
test('a thrown error and a next(err) both reach the error handler', async () => {
const thrower: RequestHandler = async () => { throw Object.assign(new Error('Nope.'), { statusCode: 403 }); };
const passer: RequestHandler = (_req, _res, next) => next(Object.assign(new Error('Missing.'), { statusCode: 404 }));
const app = appWith(
v1Route({ method: 'get', path: '/a', tag: 'T', summary: 's', access: 'public', v1: thrower }),
v1Route({ method: 'get', path: '/b', tag: 'T', summary: 's', access: 'public', v1: passer }),
);
expect((await request(app).get('/api/v2/a')).body.error).toMatchObject({ code: 'FORBIDDEN', message: 'Nope.' });
expect((await request(app).get('/api/v2/b')).body.error).toMatchObject({ code: 'NOT_FOUND', message: 'Missing.' });
});
test('middleware chains run in order', async () => {
const mark: RequestHandler = (req, _res, next) => { (req as unknown as { mark: string }).mark = 'yes'; next(); };
const handler: RequestHandler = (req, res) => { res.json({ mark: (req as unknown as { mark: string }).mark }); };
const app = appWith(v1Route({ method: 'get', path: '/m', tag: 'T', summary: 's', access: 'public', v1: [mark, handler] }));
expect((await request(app).get('/api/v2/m')).body).toEqual({ data: { mark: 'yes' } });
});
test('a file a v1 handler pipes is sent as a download', async () => {
const handler: RequestHandler = (_req, res) => {
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="ticket.pdf"');
const s = new PassThrough();
s.pipe(res);
s.write(Buffer.from('%PDF-1.4 '));
s.end(Buffer.from('end'));
};
const app = appWith(v1Route({ method: 'get', path: '/pdf', tag: 'T', summary: 's', access: 'public', file: { contentType: 'application/pdf' }, v1: handler }));
const res = await request(app).get('/api/v2/pdf').buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on('data', (d: Buffer) => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); });
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/pdf');
expect(res.headers['content-disposition']).toContain('ticket.pdf');
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
});
test("a v1 router is given the v1 URL; one with no matching route is a 404", async () => {
const router = express.Router();
router.get('/today', (req, res) => { res.json({ q: req.query.x }); });
const app = appWith(
v1Route({ method: 'get', path: '/dash', tag: 'T', summary: 's', access: 'public', v1: router, map: ctx => ({ url: '/today?x=' + String(ctx.req.query.x) }) }),
v1Route({ method: 'get', path: '/nothing', tag: 'T', summary: 's', access: 'public', v1: router, map: () => ({ url: '/missing' }) }),
);
expect((await request(app).get('/api/v2/dash?x=7')).body).toEqual({ data: { q: '7' } });
const res = await request(app).get('/api/v2/nothing');
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
test("a file v1 sends with res.download is a download", async () => {
const os = require("os"); const path = require("path");
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
require("fs").writeFileSync(file, "-- backup");
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toContain("backup.sql");
expect((res.body as Buffer).toString()).toBe("-- backup");
});
});
+2 -2
View File
@@ -82,12 +82,12 @@ describe('webhook URL', () => {
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/v2/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/v2/webhooks/yoco');
});
test('must be public https', () => {
+104 -33
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.7** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-30, after **1.51.17** (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,11 +14,23 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
| 1.51.12 | **Tickets and attendance on v2** — `resources/attendance.ts`; frontend `apiV2Raw()` for string-path helpers |
| 1.51.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
| 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.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
| 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.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` |
@@ -34,9 +46,9 @@ encrypted settings unreadable or break existing installs.
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
@@ -50,10 +62,6 @@ encrypted settings unreadable or break existing installs.
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
| 1.32.0 | Early-bird ribbon on event cards |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
@@ -61,25 +69,37 @@ encrypted settings unreadable or break existing installs.
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), `scripts/deploy.sh` |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.32.0 | Early-bird ribbon on event cards |
## Next up
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.8 – 1.51.14: 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.14 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.
1. **2.0.0** — AI helpers (§15), v1 removed, a migration guide, and marketing site/docs for
selling. Every endpoint is on v2 since 1.51.16. Keep answering the v1 webhook paths and the
old links in sent messages (`src/v2/links.ts`) after v1 goes: providers and old emails still
use them.
2. **AI rules** (decided 2026-10-29, roadmap §15): 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. Check Claude Haiku 5.5's current prices before setting
the markup and caps.
3. **Clean-ups before or with 2.0:**
- Turn the React Compiler lint rules back to errors (see [Next.js 16](#nextjs-16-1515)) — every
screen is on v2 now, but they're still `warn` in `frontend/eslint.config.mjs`.
- `node-fetch` is still listed in `backend/package.json` though nothing in `src/` uses it —
remove it.
- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still `bcryptjs`.
Decide whether it's in 2.0.
4. **Later:** 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) and Wallet passes (§7) are removed from scope (2026-10-28).
## Working on this repo
@@ -167,8 +187,8 @@ a test server (both done 2026-10-28).
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
(`routes/webhookRoutes.js`), and give it a settings card in
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there),
and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`.
@@ -185,7 +205,8 @@ a test server (both done 2026-10-28).
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
`/api/whatsapp/webhook?secret=…`; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook` or `/api/webhooks/whatsapp`, which run the same handler
since 1.51.16; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
- **Anything new that stores personal data** must be added to `anonymiseUser` and
@@ -458,13 +479,63 @@ a test server (both done 2026-10-28).
- 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.
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
- **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).
## v1 handlers behind v2 routes (1.51.11 –)
- **`v1Route({ …defineRoute options, v1: handler, map? })`** (`src/v2/viaV1.ts`) answers a v2 route
with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with
params/query/body replaced by `map(ctx)` — e.g. `map: ctx => ({ params: { eventId: ctx.params.id } })`
where v1 named it `:eventId`. The same campus middleware goes in `middleware`.
- v1 JSON → `{ data }`; a 4xx/5xx → the v2 error (v1 `code` → `reason`); a redirect →
`{ data: { redirectUrl } }`; with `file`, whatever was sent or piped → a download.
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
only allows declared ones.
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
doesn't change when it happens.
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
in the repo); calls with a computed URL are done by hand.
## 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 — all on v2 since 1.51.14 (`v1Route`).
## Setting events up on v2 (1.51.7)
- **Staff event lists:** use `fetchManagedEvents(token, { past, inactive, closed, campus })`
@@ -490,8 +561,9 @@ a test server (both done 2026-10-28).
- 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.
mostly `useEffect(() => { load(); }, [...])`. The plan was to fix them as each screen moved onto
v2; every screen is on v2 now (1.51.15) but the rules are still `warn` — fix what's left and turn
them back to errors before or with 2.0. 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.
@@ -536,13 +608,14 @@ a test server (both done 2026-10-28).
## 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
Give it the whole list (`fetchManagedEvents(token, { past: true })`, v2 `GET /events/manage`
since 1.51.7); 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.
- The managed events list 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.
@@ -553,8 +626,6 @@ a test server (both done 2026-10-28).
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
link in the message).
- `/api/webhooks/whatsapp` (`webhookController.handleWhatsappWebhook`) is an old empty stub; the
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.
+41 -43
View File
@@ -11,38 +11,35 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**Status (2026-10-27):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
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. 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** and 1.51.7 **setting events up on v2, pg-boss 11** shipped. **Next: 1.51.8 – 1.51.14 (every
endpoint onto v2), then 2.0.0.**
**Status (2026-10-30):** all features in this document are approved, and everything before 2.0.0
has shipped (1.32.0 – 1.51.17). **Next: 2.0.0** — AI helpers (§15) and v1 removed. The full
version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
- **Multi-day events** (§1): 1.33.0 phase A (sessions, late joining, per-session check-in,
attendance register, reminders, certificates); 1.35.0 phase B (pick sessions, per-session
capacity); 1.35.1 per-session waitlist, session picker at the door.
- **Platform:** 1.34.0 job queue (pg-boss), Playwright E2E tests and deploy script (the Gitea CI
workflow was removed in 1.46.1 — no runner).
- **Payments** (§2): 1.36.0 provider interface (Yoco behind it); 1.37.0 – 1.37.2 PayFast, "Pay
online", payment status checks. SnapScan and Zapper dropped (2026-10-23).
- **POPIA toolkit** (§3): 1.38.0 – 1.40.0.
- **Hub** (§13): 1.41.0 connection (licence, heartbeat, read-only mode, `/health`, install
script) with Hub 1.0.0; 1.41.3 renamed **Hope Events → Cross Events**; 1.42.0 **Request
update** with Hub 1.1.0; 1.42.1 production Hub key built in.
- **Multi-campus** (§12): 1.43.0 campuses; 1.44.0 campus-scoped staff.
- **Kids check-in** (§4) 1.45.0; **volunteer rosters** (§5) 1.46.0.
- **Event series and cloning** (§6): 1.47.0 copy an event, 1.47.1 series, 1.47.2 year-on-year
report, 1.47.3 season passes.
- **Outbound webhooks** (§8) 1.48.0.
- **Dashboard, palette, bulk actions, saved filters** (§9): 1.49.0 – 1.49.2.
- **Accessibility** (§11) 1.50.0; 1.50.1 long lists; 1.50.2 event picker and webhook guide.
- **TypeScript backend and `/api/v2`** (§14): 1.51.0 – 1.51.3 foundations, registrations and
payments, accounts and check-in, Express 5; 1.51.4 security and dependency updates; 1.51.5
Next.js 16; 1.51.6 command palette and ExcelTS; 1.51.7 – 1.51.16 every remaining area on v2 —
**every endpoint is on v2**.
- 1.51.17 security fix: forgot password and sign-in don't reveal whether an account exists.
Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
**Repositories:**
@@ -830,13 +827,13 @@ keep adding to.
| Item | What |
|------|------|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
| ~~CI (Gitea Actions)~~ | Added in 1.34.0, **removed in 1.46.1** (no machine to run a runner): lint, tests and deploy are done by hand |
| Playwright E2E ✅ | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk (1.34.0) |
| Job queue ✅ | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin (1.34.0; pg-boss 11 in 1.51.7) |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite. Partly done (1.51.1 – 1.51.10); the handlers behind `v1Route` are a 2.x clean-up |
| TypeScript backend ✅ | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend (1.51.0) |
| API v2 ✅ | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition; every endpoint on v2 since 1.51.16 |
| Dependencies | Express 5 ✅ (1.51.3); native `fetch` ✅ (nothing uses `node-fetch` any more — it's still in `backend/package.json`, to remove); password hashing upgrade with rehash-on-login — **not done** (still `bcryptjs`), decide whether it's in 2.0 |
---
@@ -865,7 +862,8 @@ never saves, sends or publishes by itself.
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
current model ($0.10 per million input tokens, $0.50 per million output, for
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
prompts under 100K tokens; prices as of 2026-10 — **check them against Anthropic's pricing
page before setting the markup and caps**). Good enough for drafting and
summarising; a helper can move to a bigger model later if quality needs it.
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
@@ -1001,7 +999,7 @@ income justify the hardware:
|-------|---------|----------|-----|
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
@@ -1011,7 +1009,7 @@ income justify the hardware:
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅, 1.51.5 ✅, 1.51.6 ✅, 1.51.7 ✅, 1.51.8 – 1.51.14 | **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 the events area (event details for staff, ticket options and variants, attachments, sections, costs, uploads, series, season passes, bundles, coupons, campuses, calendar file, alias); 1.51.9 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.10 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.11 kids check-in and volunteer rosters; 1.51.12 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.13 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.14 **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 |
| 11b | 1.51.4 ✅ – 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 puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 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/`),
@@ -1049,7 +1047,7 @@ 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.14, 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.14); 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) |
| — | 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) |
+227 -2
View File
@@ -90,8 +90,18 @@ test.describe("API v2", () => {
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();
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
// Incoming webhooks: the v1 address still works (same handler) and names its v2 one.
const hook = await request.post(`${API}/api/webhooks/whatsapp`, { data: {} });
expect(hook.status()).toBe(200);
expect(hook.headers()["link"]).toBe(`</api/v2/webhooks/whatsapp>; rel="successor-version"`);
expect((await request.post(`${API}/api/v2/webhooks/whatsapp`, { data: { event: "noop" } })).status()).toBe(200);
// A calendar link in an email already sent goes to its v2 address.
const ics = await request.get(`${API}/api/events/${seed().events.conference}/ics`, { maxRedirects: 0 });
expect(ics.status()).toBe(308);
expect(ics.headers()["location"]).toBe(`/api/v2/events/${seed().events.conference}/calendar.ics`);
const followed = await request.get(`${API}/api/events/${seed().events.conference}/ics`);
expect(followed.headers()["content-type"]).toContain("text/calendar");
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
@@ -116,12 +126,227 @@ test.describe("API v2", () => {
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("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const providers = await request.get(`${API}/api/v2/payments/providers`);
expect(providers.status()).toBe(200);
expect(await providers.json()).toHaveProperty("data");
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
expect(cash.status()).toBe(200);
expect((await cash.json()).data).toBeTruthy();
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
expect(mine.status()).toBe(200);
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
const res = await request.get(`${API}${path}`);
expect(res.status(), path).toBeGreaterThanOrEqual(400);
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
}
});
test("tickets and attendance: mine, the register, a spreadsheet download, links that don't exist", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const mine = await request.get(`${API}/api/v2/me/tickets`, { headers: user });
expect(mine.status()).toBe(200);
expect(Array.isArray((await mine.json()).data)).toBe(true);
const reg = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance`, { headers: staff });
expect(reg.status()).toBe(200);
const xlsx = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance/export?format=xlsx`, { headers: staff });
expect(xlsx.status()).toBe(200);
expect(xlsx.headers()["content-type"]).toContain("spreadsheetml");
expect(xlsx.headers()["content-disposition"]).toContain(".xlsx");
expect((await xlsx.body()).subarray(0, 2).toString()).toBe("PK");
// A code that isn't a certificate is an answer, not an error.
expect((await (await request.get(`${API}/api/v2/certificates/verify/NO-SUCH-CODE`)).json()).data).toEqual({ valid: false });
const transfer = await request.get(`${API}/api/v2/ticket-transfers/not-a-real-token`);
expect(transfer.status()).toBeGreaterThanOrEqual(400);
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
});
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
const res = await request.get(`${API}${path}`, { headers });
expect(res.status(), path).toBe(200);
expect(await res.json(), path).toHaveProperty("data");
}
// Staff lists aren't for everyone.
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
});
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
expect(Array.isArray(listing)).toBe(true);
expect(listing[0]).toHaveProperty("eventOptions");
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
const res = await request.get(`${API}${path}`, { headers: staff });
expect(res.status(), path).toBe(200);
}
// Saved filters run the v1 router: save one, list it, delete it.
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
expect(saved.status()).toBe(200);
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
expect(mine).toBeTruthy();
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
expect(exported.status()).toBe(200);
expect(exported.headers()["content-disposition"]).toContain("my-data-");
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
const res = await request.get(`${API}${path}`, { headers: admin });
expect(res.status(), path).toBe(200);
}
// Admin areas aren't for everyone.
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
});
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();
// Sections start collapsed (there are hundreds of endpoints): open Events.
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
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=");
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.7",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.7",
"version": "1.51.17",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.7",
"version": "1.51.17",
"private": true,
"scripts": {
"dev": "next dev",
@@ -2,9 +2,9 @@
import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
try {
setLoading(true);
setStatus(null);
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
const res = await (apiV2Data("post", "/auth/password/forgot", { body: { email } }) as Promise<{ message?: string }>);
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
} catch (err) {
try {
+2 -2
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { apiFetch } from "@/lib/api";
import type { Event } from "@/types";
export const revalidate = 60;
@@ -20,7 +19,8 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
if (VALID_ALIAS.test(redirectUrl)) {
try {
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
const { apiV2 } = await import("@/lib/apiV2");
event = (await apiV2("get", "/events/by-alias/{alias}", { params: { alias: redirectUrl } })).data as unknown as Event;
} catch (error) {
console.error("Failed to fetch event:", error);
}
+2 -2
View File
@@ -3,12 +3,12 @@ import React, { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ActivateAccountContent() {
const search = useSearchParams();
@@ -29,7 +29,7 @@ function ActivateAccountContent() {
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
const data = await (apiV2Data("post", "/auth/activate", { body: { token, password, acceptTerms: accepted } }) as Promise<{ token?: string }>);
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
+4 -3
View File
@@ -5,10 +5,11 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { ApiImage } from "@/components/shared/ApiImage";
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
import { apiFetchListForPrerender, ApiError } from "@/lib/api";
import { mapsSearchUrl } from "@/lib/maps";
import { CalendarX, MapPin, Mail, Phone, User, ArrowLeft } from "lucide-react";
import type { Campus } from "@/hooks/useCampuses";
import { apiV2Data } from "@/lib/apiV2";
export const revalidate = 60;
@@ -16,7 +17,7 @@ type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?:
async function loadCampus(slug: string): Promise<Campus | null> {
try {
return await apiFetch<Campus>(`/api/campuses/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
return await (apiV2Data("get", "/campuses/by-slug/{slug}", { params: { slug }, nextOptions: { next: { revalidate } } }) as Promise<Campus>);
} catch (e) {
// 404: no such campus, archived, or campuses are off.
if (e instanceof ApiError && e.status === 404) return null;
@@ -41,7 +42,7 @@ export default async function CampusPage({ params }: { params: Promise<{ slug: s
const campus = await loadCampus(slug);
if (!campus) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
@@ -2,8 +2,8 @@ import { Metadata } from "next";
import { BadgeCheck, ShieldX } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { apiV2Data } from "@/lib/apiV2";
export const dynamic = "force-dynamic";
@@ -20,7 +20,7 @@ export default async function VerifyCertificatePage({ params }: { params: Promis
const { code } = await params;
let result: Verification = { valid: false };
try {
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
result = await (apiV2Data("get", "/certificates/verify/{code}", { params: { code: code }, nextOptions: { cache: "no-store" } }) as Promise<Verification>);
} catch {
result = { valid: false };
}
+5 -4
View File
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type ConsentInfo = {
status: "pending" | "given" | "declined";
@@ -39,7 +40,7 @@ export default function ConsentPage() {
useEffect(() => {
if (!token) return;
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
(apiV2Data("get", "/consent/{token}", { params: { token: token } }) as Promise<ConsentInfo>)
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
}, [token]);
@@ -50,8 +51,8 @@ export default function ConsentPage() {
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
setBusy(true);
try {
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
method: "POST",
await apiV2Data("post", "/consent/{token}", {
params: { token: token },
body: {
decision, signedName, isGuardian,
...(decision === "give" && info.collect.medical ? { medical } : {}),
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { History } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
@@ -131,7 +131,7 @@ function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
(apiV2Data("get", "/admin/impersonation-sessions", { query: { limit: "20" }, authToken: token }) as Promise<ImpSession[]>).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
@@ -196,7 +196,7 @@ export default function AdminAuditLogPage() {
if (search) qs.set("search", search);
if (fromFilter) qs.set("from", fromFilter);
if (toFilter) qs.set("to", toFilter);
return `/api/admin/audit-log?${qs.toString()}`;
return Object.fromEntries(qs);
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
// Search as you type, once typing pauses.
@@ -215,7 +215,7 @@ export default function AdminAuditLogPage() {
setError(null);
setFetching(true);
try {
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
const res = await (apiV2Data("get", "/admin/audit-log", { query: buildQuery(p), authToken: token }) as Promise<{ rows: AuditLogEntry[]; total: number; page: number }>);
setEntries(res?.rows || []);
setTotal(res?.total ?? 0);
setPage(p);
@@ -3,12 +3,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
import { apiV2Data } from "@/lib/apiV2";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -41,8 +43,8 @@ export default function EventCashupPage() {
setError(null);
try {
const [financials, event] = await Promise.all([
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
(apiV2Data("get", "/events/{id}/cashup", { params: { id: eventId }, authToken: token }) as Promise<EventFinancials>),
fetchEvent<Event>(eventId, { authToken: token })
]);
setData(financials);
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
@@ -142,11 +144,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
setSaving(true);
setErr(null);
try {
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null };
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: (paidFromMethod || null) as "cash" | "card" | "eft" | "other" | null, notes: notes || null };
if (editingId === "new") {
await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body });
await createCost(token, eventId, body);
} else {
await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body });
await updateCost(token, editingId!, body);
}
setEditingId(null);
onChanged();
@@ -160,7 +162,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
const remove = async (id: string) => {
if (!confirm("Delete this cost?")) return;
try {
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
await deleteCost(token, id);
onChanged();
} catch (e) {
alert(errorMessage(e) || "Failed to delete cost");
@@ -308,7 +310,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
const load = useCallback(() => {
if (!token || !eventId) return;
setLoading(true);
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
(apiV2Data("get", "/events/{id}/cashup/cash-by-user", { params: { id: eventId }, authToken: token }) as Promise<AccountabilityResponse>)
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
.finally(() => setLoading(false));
@@ -457,8 +459,10 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
const save = async () => {
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
await apiV2Data("put", "/events/{id}/cashup/person-cash/{userId}", {
params: { id: eventId, userId: userId },
authToken: token,
body: { denominations: lines, notes: notes || null },
});
onSaved();
} catch (e) {
@@ -832,7 +836,7 @@ function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod;
if (!r.lineId || !mode) return;
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
await apiV2Data("post", "/cashup-lines/{id}/pending", { params: { id: r.lineId }, authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
setMode(null);
onChanged();
} catch (e) {
@@ -971,7 +975,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
const saveDraft = async () => {
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
await apiV2Data("put", "/events/{id}/cashup/draft", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload() } });
onChanged();
} catch (e) {
setError(errorMessage(e) || "Failed to save draft");
@@ -991,7 +995,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e) {
@@ -1005,7 +1009,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e) {
@@ -1019,7 +1023,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/reopen", { params: { id: eventId }, authToken: token, body: { notes: reopenNotes || null } });
onChanged();
} catch (e) {
setError(errorMessage(e) || "Failed to reopen event");
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { Pager } from "@/components/shared/Pager";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Inbox, AlertTriangle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
@@ -44,8 +44,8 @@ function OpenRequest({ request, token, onDone }: { request: DataRequest; token:
setBusy(true);
setError(null);
try {
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
method: "POST",
await apiV2Data("post", mode === "reject" ? "/privacy/requests/{id}/reject" : "/privacy/requests/{id}/complete", {
params: { id: request.id },
authToken: token,
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
});
@@ -155,7 +155,7 @@ export default function DataRequestsPage() {
try {
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
if (search) qs.set("search", search);
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
const res = await (apiV2Data("get", "/privacy/requests", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ requests: DataRequest[]; openCount: number; total: number }>);
setRequests(res.requests);
setOpenCount(res.openCount);
setTotal(res.total ?? res.requests.length);
+3 -3
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -93,9 +93,9 @@ export default function AdminDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [dash, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
(apiV2Data("get", "/stats/admin", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -12,6 +12,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -80,7 +81,7 @@ export default function AdminRegistrationsPage() {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const regs = await fetchAllRegistrations(token);
const list = Array.isArray(regs) ? regs.sort((a, b) => {
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
if (eventCompare !== 0) return eventCompare;
@@ -124,7 +125,7 @@ export default function AdminRegistrationsPage() {
if (!formResponses[id] && !loadingForms.has(id)) {
setLoadingForms(prev => new Set(prev).add(id));
try {
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
const res = await (apiV2Data("get", "/form-responses", { query: { registrationId: id }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
setFormResponses(prev => ({ ...prev, [id]: items }));
} catch {
@@ -140,7 +141,7 @@ export default function AdminRegistrationsPage() {
setError(null); setInfo(null);
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
try {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
await apiV2Data("post", "/registrations/{id}/cancel", { params: { id: reg.id }, authToken: token });
setInfo("Registration cancelled");
await loadRegistrations();
} catch (e) {
@@ -152,7 +153,7 @@ export default function AdminRegistrationsPage() {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
await apiV2Data("patch", "/registrations/{id}/status", { params: { id: reg.id }, authToken: token, body: { status: status as "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled" } });
setInfo("Status updated");
await loadRegistrations();
} catch (e) {
@@ -4,7 +4,8 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { ApiError, resolveToApiOrigin } from "@/lib/api";
import { apiV2Raw, apiV2Data } from "@/lib/apiV2";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
@@ -20,6 +21,7 @@ import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { uploadImage } from "@/lib/eventSetup";
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
@@ -163,7 +165,7 @@ function SiteSettingsPageInner() {
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>)
.then((s) => {
setOrgName(s.org_name || "");
setOrgTagline(s.org_tagline || "");
@@ -211,7 +213,7 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e) {
@@ -232,16 +234,11 @@ function SiteSettingsPageInner() {
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
try {
return await uploadImage(token, kind, file);
} catch {
throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
}
};
const saveBranding = async () => {
@@ -255,8 +252,8 @@ function SiteSettingsPageInner() {
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
@@ -287,8 +284,8 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
@@ -329,7 +326,7 @@ function SiteSettingsPageInner() {
smtp_user: smtpUser.trim(),
};
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
@@ -355,8 +352,7 @@ function SiteSettingsPageInner() {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
await apiFetch("/api/settings/test-smtp", {
method: "POST",
await apiV2Data("post", "/settings/test-smtp", {
authToken: token,
body: {
host: smtpHost.trim(),
@@ -888,7 +884,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/config", { authToken: token }) as Promise<ConfigResponse>);
setCfg(res);
} catch {
// network error — leave cfg null, tab shows loading state
@@ -902,7 +898,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/status", { authToken: token }) as Promise<StatusResponse>);
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch {
@@ -925,7 +921,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/qr", { authToken: token }) as Promise<{ qr?: string }>);
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
@@ -949,7 +945,9 @@ function WhatsAppTab({ active }: { active: boolean }) {
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
const res = action === "delete-instance"
? await apiV2Raw<{ message?: string }>("delete", "/whatsapp/instance", { authToken: token })
: await apiV2Raw<{ message?: string }>("post", `/whatsapp/${action}`, { authToken: token, body: body || {} });
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
@@ -974,7 +972,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: inputToken.trim(), instanceId: "" } });
setInputToken("");
await fetchConfig();
} catch (e) {
@@ -992,7 +990,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "", instanceId: inputInstanceId.trim() } });
setInputInstanceId("");
await fetchConfig();
} catch (e) {
@@ -1006,7 +1004,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
const res = await (apiV2Data("post", "/whatsapp/instance", { authToken: token, body: {} }) as Promise<{ message?: string }>);
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e) {
@@ -1027,7 +1025,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "_clear_", instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "_clear_", instanceId: "" } });
} catch {}
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
@@ -1261,9 +1259,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
await apiV2Data("put", "/whatsapp/config", {
authToken: token,
body: { token: inputToken.trim() || undefined, instanceId: inputInstanceId.trim() || undefined },
});
setActionMsg({ type: "ok", text: "Credentials updated." });
+21 -17
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
@@ -13,6 +12,7 @@ import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
interface UserItem {
id: string;
@@ -91,7 +91,7 @@ export default function AdminUsersPage() {
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
if (roleFilter) qs.set("role", roleFilter);
if (activeFilter !== "") qs.set("isActive", activeFilter);
return `/api/users?${qs.toString()}`;
return Object.fromEntries(qs);
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
@@ -99,8 +99,7 @@ export default function AdminUsersPage() {
setError(null);
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: buildQuery(p, ps), authToken: token }) as Promise<Paginated<UserItem>>);
setUsers(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
@@ -131,13 +130,16 @@ export default function AdminUsersPage() {
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<{ id?: string }>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
// An admin adding someone is a sign-up on their behalf (they agreed to the terms for them).
const { data: made } = await apiV2("post", "/auth/signup", {
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, acceptTerms: true, ...(cPhone ? { phone: cPhone } : {}) },
});
const created = { id: made.user.id };
if (cRole && cRole !== "user" && created?.id) {
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
method: "PUT", authToken: token!, body: { role: cRole },
await apiV2Data("patch", "/users/{id}", {
params: { id: created.id },
authToken: token,
body: { role: cRole },
});
}
resetCreateForm();
@@ -156,7 +158,7 @@ export default function AdminUsersPage() {
const [scope, setScope] = useState<{ id: string; name: string }[] | null>(null);
const startEdit = (u: UserItem) => {
setEditingId(u.id); setEditData({ ...u, password: "" }); setScope(null);
apiFetch<{ scopedCampuses?: { id: string; name: string }[] }>(`/api/users/${encodeURIComponent(u.id)}`, { authToken: token! })
(apiV2Data("get", "/users/{id}", { params: { id: u.id }, authToken: token }) as Promise<{ scopedCampuses?: { id: string; name: string }[] }>)
.then(full => setScope(full.scopedCampuses || []))
.catch(() => setScope(null));
};
@@ -181,8 +183,10 @@ export default function AdminUsersPage() {
payload.password = editData.password.trim();
}
if (campusInfo.enabled && canBeScoped && scope) payload.scopedCampusIds = scope.map(c => c.id);
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
await apiV2Data("patch", "/users/{id}", {
params: { id: editingId },
authToken: token,
body: payload,
});
setEditingId(null);
setEditData({});
@@ -197,7 +201,7 @@ export default function AdminUsersPage() {
const revokeUserSessions = async (id: string, name: string) => {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/sessions/revoke", { params: { id: id }, authToken: token, body: {} });
} catch (e) {
setError(errorMessage(e) || "Failed to revoke sessions");
}
@@ -212,7 +216,7 @@ export default function AdminUsersPage() {
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
const res = await (apiV2Data("post", "/admin/impersonate/{userId}", { params: { userId: impTarget.id }, authToken: token, body: { reason: impReason } }) as Promise<ImpersonationStart>);
beginImpersonation(res);
} catch (e) {
setImpError(errorMessage(e) || "Couldn't start impersonation");
@@ -228,7 +232,7 @@ export default function AdminUsersPage() {
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
if (!confirm(message)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await apiV2Data("patch", "/users/{id}", { params: { id: id }, authToken: token, body: { twoFactorEnabled: false } });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to reset two-factor authentication");
@@ -238,7 +242,7 @@ export default function AdminUsersPage() {
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await apiV2Data("delete", "/users/{id}", { params: { id: id }, authToken: token });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to deactivate user");
@@ -248,7 +252,7 @@ export default function AdminUsersPage() {
const deleteUserData = async (id: string, name: string) => {
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/anonymize", { params: { id: id }, authToken: token, body: {} });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to delete user data");
@@ -2,7 +2,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
@@ -13,6 +13,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, Ticket as TicketRecord } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -82,10 +83,10 @@ function EventTicketsContent() {
setLoading(true);
setError(null);
try {
const list = await apiFetch<TicketRecord[]>(
`/api/tickets/event/${encodeURIComponent(eventId)}`,
{ authToken: token }
);
const list = await (apiV2Data("get", "/events/{id}/tickets", {
params: { id: eventId },
authToken: token,
}) as Promise<TicketRecord[]>);
setTickets(list);
} catch (e) {
setError(errorMessage(e) || "Failed to load tickets");
+29 -23
View File
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -14,6 +14,7 @@ import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard } from "@/components/kids/RoomBoard";
import { StickerPanel } from "@/components/kids/StickerPanel";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { apiV2Data } from "@/lib/apiV2";
import {
FAMILY_QR_PREFIX, QR_PREFIX, deviceId, loadOffline, offlineCheckIn, saveOffline, stickerAlert, suggestRoom,
type CheckInResult, type KidsBoard, type KidsFamily, type OfflineState,
@@ -64,19 +65,18 @@ export default function KidsDeskPage() {
const [collectedBy, setCollectedBy] = useState("");
const [wrongCode, setWrongCode] = useState<string | null>(null);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const loadBoard = useCallback(async (log = false) => {
if (!token || !eventId || working === "offline") return;
try {
const data = await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token });
const data = await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>);
setBoard(data);
setBoardError(null);
} catch (e) {
setBoard(null);
setBoardError(errorMessage(e) || "Could not load the desk");
}
}, [token, eventId, qs, working]);
}, [token, eventId, sessionId, working]);
useEffect(() => {
setBoard(null);
@@ -145,7 +145,7 @@ export default function KidsDeskPage() {
return;
}
try {
setResults(await apiFetch<KidsFamily[]>(`/api/kids/events/${eventId}/families?q=${encodeURIComponent(term)}`, { authToken: token }));
setResults(await (apiV2Data("get", "/events/{id}/kids/families", { params: { id: eventId }, query: { q: term }, authToken: token }) as Promise<KidsFamily[]>));
} catch (err) {
fail(err, "Search failed");
}
@@ -163,7 +163,7 @@ export default function KidsDeskPage() {
if (!t.toUpperCase().startsWith(FAMILY_QR_PREFIX)) { setMsg({ type: "err", text: "That isn't a family or pickup QR code." }); return; }
if (working === "offline") { setMsg({ type: "err", text: "Family QR codes need a connection — search by name or phone instead." }); return; }
try {
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/qr/${encodeURIComponent(t.slice(FAMILY_QR_PREFIX.length))}`, { authToken: token }));
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/qr/{token}", { params: { id: eventId, token: t.slice(FAMILY_QR_PREFIX.length) }, authToken: token }) as Promise<KidsFamily>));
} catch (err) {
fail(err, "Family not found");
}
@@ -173,10 +173,11 @@ export default function KidsDeskPage() {
if (!newFamily) return;
setBusy(true);
try {
const f = await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families`, {
method: "POST", authToken: token,
const f = await (apiV2Data("post", "/events/{id}/kids/families", {
params: { id: eventId },
authToken: token,
body: { name: newFamily.name, parent: { name: newFamily.parentName, phone: newFamily.phone, email: newFamily.email }, children: [child] },
});
}) as Promise<KidsFamily>);
setNewFamily(null);
chooseFamily(f);
setMsg({ type: "ok", text: `${f.name} added.` });
@@ -191,8 +192,8 @@ export default function KidsDeskPage() {
if (!family) return;
setBusy(true);
try {
await apiFetch(`/api/kids/events/${eventId}/families/${family.id}/children`, { method: "POST", authToken: token, body: child });
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/${family.id}`, { authToken: token }));
await apiV2Data("post", "/events/{id}/kids/families/{familyId}/children", { params: { id: eventId, familyId: family.id }, authToken: token, body: child });
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/{familyId}", { params: { id: eventId, familyId: family.id }, authToken: token }) as Promise<KidsFamily>));
setMsg({ type: "ok", text: `${child.firstName} added.` });
} catch (err) {
fail(err, "Could not add the child");
@@ -225,9 +226,11 @@ export default function KidsDeskPage() {
}
setBusy(true);
try {
const r = await apiFetch<CheckInResult>(`/api/kids/events/${eventId}/check-in`, {
method: "POST", authToken: token, body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
});
const r = await (apiV2Data("post", "/events/{id}/kids/check-in", {
params: { id: eventId },
authToken: token,
body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
}) as Promise<CheckInResult>);
setResult(r);
loadBoard();
} catch (err) {
@@ -268,7 +271,7 @@ export default function KidsDeskPage() {
}
} else {
try {
l = await apiFetch<CodeLookup>(`/api/kids/events/${eventId}/code/${encodeURIComponent(raw.trim())}${qs}`, { authToken: token });
l = await (apiV2Data("get", "/events/{id}/kids/code/{code}", { params: { id: eventId, code: raw.trim() }, query: { sessionId: sessionId || undefined }, authToken: token }) as Promise<CodeLookup>);
} catch (err) {
fail(err, "Lookup failed");
return;
@@ -276,7 +279,7 @@ export default function KidsDeskPage() {
if (!l.found) {
// Record the wrong code (alerts the rooms) through the check-out call.
try {
await apiFetch(`/api/kids/events/${eventId}/check-out`, { method: "POST", authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
await apiV2Data("post", "/events/{id}/kids/check-out", { params: { id: eventId }, authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
} catch (err) {
setWrongCode(errorMessage(err) || "Wrong code");
}
@@ -306,9 +309,11 @@ export default function KidsDeskPage() {
}
setBusy(true);
try {
const r = await apiFetch<{ checkedOut: { name: string }[] }>(`/api/kids/events/${eventId}/check-out`, {
method: "POST", authToken: token, body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
});
const r = await (apiV2Data("post", "/events/{id}/kids/check-out", {
params: { id: eventId },
authToken: token,
body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
}) as Promise<{ checkedOut: { name: string }[] }>);
setMsg({ type: "ok", text: `Collected: ${r.checkedOut.map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
@@ -325,7 +330,7 @@ export default function KidsDeskPage() {
const getReady = async () => {
setBusy(true);
try {
const snapshot = await apiFetch<OfflineState["snapshot"]>(`/api/kids/events/${eventId}/offline?deviceId=${encodeURIComponent(deviceId())}${sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` : ""}`, { authToken: token });
const snapshot = await (apiV2Data("get", "/events/{id}/kids/offline", { params: { id: eventId }, query: { deviceId: deviceId(), sessionId: sessionId || undefined }, authToken: token }) as Promise<OfflineState["snapshot"]>);
const prev = loadOffline(eventId);
const sameOccasion = prev?.snapshot.occasion === snapshot.occasion;
const state: OfflineState = {
@@ -348,10 +353,11 @@ export default function KidsDeskPage() {
if (!offline) return;
setBusy(true);
try {
const r = await apiFetch<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>(`/api/kids/events/${eventId}/sync`, {
method: "POST", authToken: token,
const r = await (apiV2Data("post", "/events/{id}/kids/sync", {
params: { id: eventId },
authToken: token,
body: { deviceId: deviceId(), occasion: offline.snapshot.occasion, checkIns: offline.checkIns, checkOuts: offline.checkOuts },
});
}) as Promise<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>);
const next: OfflineState = { ...offline, checkIns: [], checkOuts: [] };
saveOffline(eventId, next);
setOffline(next);
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard, RoomStatusBadges } from "@/components/kids/RoomBoard";
import type { KidsBoard } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
const ROOM_KEY = "hope_events_kids_room";
@@ -26,17 +26,16 @@ export default function KidsRoomsPage() {
useEffect(() => { try { setRoomId(localStorage.getItem(ROOM_KEY) || ""); } catch { /* ignore */ } }, []);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const load = useCallback(async (log = false) => {
if (!token || !eventId) return;
try {
setBoard(await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token }));
setBoard(await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>));
setError(null);
} catch (e) {
setBoard(null);
setError(errorMessage(e) || "Could not load the rooms");
}
}, [token, eventId, qs]);
}, [token, eventId, sessionId]);
useEffect(() => { load(true); }, [load]);
useVisiblePolling(() => { load(); }, 5000, !!token && !!eventId);
@@ -49,7 +48,7 @@ export default function KidsRoomsPage() {
const onDuty = !!room && !!user && room.leaderIds.includes(user.id);
const duty = async (on: boolean) => {
try {
await apiFetch(`/api/kids/events/${eventId}/duty`, { method: "POST", authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
await apiV2Data("post", "/events/{id}/kids/duty", { params: { id: eventId }, authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
load();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not change duty" });
@@ -2,10 +2,10 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type Member = {
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
@@ -30,7 +30,7 @@ export default function MyGroupsPage() {
useEffect(() => {
if (!token) return;
apiFetch<{ medicalShown: boolean; groups: MyGroup[] }>("/api/allocations/my-groups", { authToken: token })
(apiV2Data("get", "/me/allocation-groups", { authToken: token }) as Promise<{ medicalShown: boolean; groups: MyGroup[] }>)
.then(setData).catch(e => setError(e?.message || "Failed to load your groups"));
}, [token]);
+2 -2
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
@@ -13,6 +12,7 @@ import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -52,7 +52,7 @@ export default function StaffDashboardPage() {
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
const data = await (apiV2Data("get", "/stats/staff", { authToken: token }) as Promise<DashboardStats>);
setStats(data.scanStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch {
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
@@ -14,7 +14,8 @@ import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { listSections } from "@/lib/eventSetup";
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
// same shape from the downloaded ticket list.
@@ -120,7 +121,7 @@ export default function TicketScanningPage() {
}
try {
setLoadingSections(true);
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
const list = await listSections<Section>(token, selectedEventId);
setSections(Array.isArray(list) ? list : []);
setSelectedSectionId("all");
} catch {
@@ -329,7 +330,7 @@ export default function TicketScanningPage() {
const loadRecent = useCallback(async () => {
if (!token) return;
try {
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
const list = await (apiV2Data("get", "/tickets/scans/recent", { query: { limit: "10" }, authToken: token }) as Promise<RecentScan[]>);
setRecentScans(list);
} catch {}
}, [token]);
@@ -337,7 +338,7 @@ export default function TicketScanningPage() {
if (!token) return;
try {
setLoadingStats(true);
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
const s = await (apiV2Data("get", "/tickets/scans/stats", { authToken: token }) as Promise<ScanStats>);
setStats(s);
} catch {} finally {
setLoadingStats(false);
@@ -2,7 +2,6 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import type { RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
export default function StaffVolunteersPage() {
@@ -22,7 +22,7 @@ export default function StaffVolunteersPage() {
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
(apiV2Data("get", "/volunteering/events", { authToken: token }) as Promise<RosterEvent[]>)
.then(list => {
setEvents(list);
const now = Date.now();
@@ -4,13 +4,14 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
@@ -107,7 +108,7 @@ export default function AllocationsPage() {
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const b = await (apiV2Data("get", "/events/{id}/allocations", { params: { id: eventId }, authToken: token }) as Promise<Board>);
setBoard(b);
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
} catch (e) {
@@ -121,8 +122,9 @@ export default function AllocationsPage() {
const membersOf = (groupId: string) => (board?.attendees || []).filter(p => groupOf.get(p.id) === groupId);
const unassigned = (board?.attendees || []).filter(p => !groupOf.has(p.id));
const call = async <T,>(path: string, method: string, body?: unknown): Promise<T> =>
apiFetch<T>(path, { method, authToken: token!, ...(body !== undefined ? { body } : {}) });
// Paths are API v2's.
const call = async <T,>(path: string, method: "get" | "post" | "put" | "patch" | "delete", body?: unknown): Promise<T> =>
apiV2Raw<T>(method, path, { authToken: token, ...(body !== undefined ? { body } : {}) });
const onDragEnd = async (e: DragEndEvent) => {
if (!current || !e.over) return;
@@ -139,7 +141,7 @@ export default function AllocationsPage() {
}),
}));
try {
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
await call(`/allocation-sets/${current.id}/assignments`, "put", { attendeeId, groupId });
} catch (err) {
setError(errorMessage(err) || "Couldn't move them");
load();
@@ -160,21 +162,21 @@ export default function AllocationsPage() {
};
const addSet = () => run(async () => {
const s = await call<{ id: string }>("/api/allocations/sets", "POST", { eventId, name: newSetName || "Rooms" });
const s = await call<{ id: string }>("/allocation-sets", "post", { eventId, name: newSetName || "Rooms" });
setNewSetName("");
setSetId(s.id);
});
const saveGroup = () => groupForm && current && run(async () => {
const body = { name: groupForm.name, capacity: groupForm.capacity || null, gender: groupForm.gender || null, leaderUserId: groupForm.leaderUserId || null };
if (groupForm.id) await call(`/api/allocations/groups/${groupForm.id}`, "PUT", body);
else await call(`/api/allocations/sets/${current.id}/groups`, "POST", body);
if (groupForm.id) await call(`/allocation-groups/${groupForm.id}`, "patch", body);
else await call(`/allocation-sets/${current.id}/groups`, "post", body);
setGroupForm(null);
});
const printLists = async (master: boolean, medical: boolean) => {
if (!current || !board) return;
try {
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
const data = await apiV2Raw<SetLists>("get", `/allocation-sets/${current.id}/lists`, { query: { medical: medical ? "true" : undefined }, authToken: token });
const w = window.open("", "_blank");
if (!w) return;
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
@@ -249,19 +251,19 @@ export default function AllocationsPage() {
<>
<div className="flex flex-wrap items-center gap-2 border rounded-xl p-3 bg-white">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/api/allocations/sets/${current.id}`, "PUT", { showOnTicket: e.target.checked }); })} />
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/allocation-sets/${current.id}`, "patch", { showOnTicket: e.target.checked }); })} />
Show {current.name.toLowerCase()} on tickets and in My Events
</label>
<div className="ml-auto flex flex-wrap gap-2">
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/auto`, "post", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/auto`, "post", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
<button onClick={() => printLists(false, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Group lists</button>
<button onClick={() => printLists(true, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Master list</button>
{board.event.requiresParentalConsent && (
<button onClick={() => printLists(false, true)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50" title="Includes medical details from consent forms. Viewing is recorded in the audit log."><Printer className="w-4 h-4" /> Lists with medical info</button>
)}
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/assignments`, "DELETE")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
<button disabled={busy} onClick={() => run(async () => { await call(`/api/allocations/sets/${current.id}`, "DELETE"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/assignments`, "delete")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
<button disabled={busy} onClick={() => run(async () => { await call(`/allocation-sets/${current.id}`, "delete"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
</div>
</div>
@@ -304,7 +306,7 @@ export default function AllocationsPage() {
<Column key={g.id} id={g.id} title={g.name} subtitle={subtitle} full={full} actions={
<div className="flex gap-1 shrink-0">
<button aria-label={`Edit ${g.name}`} onClick={() => setGroupForm({ id: g.id, name: g.name, capacity: g.capacity ? String(g.capacity) : "", gender: g.gender || "", leaderUserId: g.leaderUserId || "" })} className="p-1 rounded hover:bg-gray-100"><Pencil className="w-3.5 h-3.5" /></button>
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/api/allocations/groups/${g.id}`, "DELETE"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/allocation-groups/${g.id}`, "delete"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
}>
{members.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -19,7 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -162,7 +162,7 @@ export default function AtTheDoorPage() {
(async () => {
try {
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
const ev = await fetchEvent<Event>(eventId, {
authToken: token
});
@@ -265,20 +265,20 @@ export default function AtTheDoorPage() {
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
});
let res = await (apiV2Data("put", "/registrations/{id}/items", {
params: { id: pendingEditReg.id },
authToken: token,
body: { options: opts },
}) as Promise<Registration>);
let message = "Registration updated";
if (doorCoupon) {
// Applied after the items are saved, so it's worked out on the final items.
try {
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
method: "POST",
authToken: token,
body: { code: doorCoupon.code },
});
res = await (apiV2Data("post", "/registrations/{id}/coupon", {
params: { id: pendingEditReg.id },
authToken: token,
body: { code: doorCoupon.code },
}) as Promise<Registration>);
message = `Registration updated — code ${doorCoupon.code} applied`;
} catch (ce) {
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
@@ -290,10 +290,9 @@ export default function AtTheDoorPage() {
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
const res = await (apiV2Data("post", "/registrations/manual", {
authToken: token,
body: {
eventId,
user: {
name: pendingUser.name,
@@ -306,8 +305,8 @@ export default function AtTheDoorPage() {
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
: {}),
}
});
},
}) as Promise<Registration & { couponError?: string }>);
setShowOptionsModal(false);
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
handleRegistrationCreated(res);
@@ -360,7 +359,7 @@ export default function AtTheDoorPage() {
const handleEditRegistration = async (reg: Registration) => {
let freshReg = reg;
try {
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
freshReg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: reg.id }, authToken: token }) as Promise<Registration>);
} catch (e) {
setError(errorMessage(e) || "Failed to load latest registration data");
return;
@@ -575,7 +574,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
const load = async () => {
try {
setLoading(true);
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
if (!cancelled) setAllRegs(regs || []);
} catch {
if (!cancelled) setAllRegs([]);
@@ -601,7 +600,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
setAllRegs(regs || []);
} catch {} finally { setLoading(false); }
}}
@@ -705,19 +704,19 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments", {
authToken: token,
body: {
registrationId: registration.id,
amount: parseFloat(amount),
method
}
},
});
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
authToken: token
});
const updated = await (apiV2Data("get", "/registrations/{id}/full", {
params: { id: registration.id },
authToken: token,
}) as Promise<Registration>);
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
@@ -886,7 +885,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
(async () => {
try {
setLoading(true);
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
setAllTickets(ticketsFrom(res));
} catch {
setAllTickets([]);
@@ -920,7 +919,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
setAllTickets(ticketsFrom(res));
} catch {
setAllTickets([]);
@@ -1044,7 +1043,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
const tickets = ticketsFrom(res);
setTicketsForEvent(tickets.filter((t) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
@@ -1426,16 +1425,15 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
method,
userId: user?.id || undefined,
eventId,
isDonation: true
}
},
});
onSuccess?.();
@@ -1538,10 +1536,9 @@ function SendTicketsModal({ open, onClose, token, registration }: {
}
setSending(true);
try {
await apiFetch("/api/tickets/send-to", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/tickets/send", {
authToken: token,
body: {
registrationId: registration?.id,
channel,
overrideEmail: email.trim() || undefined,
@@ -1665,19 +1662,18 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
setSaving(true);
setLocalError("");
try {
await apiFetch("/api/payments/refund", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments/refunds", {
authToken: token,
body: {
userId: registration.userId,
registrationId: registration.id,
amount: amt,
method,
reason: reason || undefined,
}
},
});
// Re-fetch registration to get updated status & payments
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
const updated = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registration.id }, authToken: token }) as Promise<Registration>);
onRefunded(updated);
} catch (e) {
setLocalError(errorMessage(e) || "Refund failed.");
@@ -1778,7 +1774,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
setAllRegs(regs || []);
} catch {
setAllRegs([]);
@@ -1800,7 +1796,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
setSelectedReg(updated);
setInfo("Refund recorded");
// Refresh list
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>).catch(() => null);
if (regs) setAllRegs(regs);
};
@@ -4,13 +4,14 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from "reac
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { API_BASE, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
@@ -57,7 +58,7 @@ function AttendancePageInner() {
if (!token || !eventId) return;
setBusy(true);
try {
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
setReg(await (apiV2Data("get", "/events/{id}/attendance", { params: { id: eventId }, authToken: token }) as Promise<AttendanceRegister>));
} catch (e) {
setError(errorMessage(e) || "Failed to load the register");
} finally {
@@ -93,7 +94,7 @@ function AttendancePageInner() {
try {
// Picked sessions have a ticket per session.
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
await apiV2Data("post", "/events/{id}/attendance", { params: { id: eventId }, authToken: token, body: { ticketId, sessionId, present } });
const col = reg?.columns.find(c => c.id === sessionId);
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
await load();
@@ -108,7 +109,7 @@ function AttendancePageInner() {
if (!token) return;
setBusy(true);
try {
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
const r = await apiV2Raw<Record<string, number>>("post", path, { authToken: token, body });
setInfo(done(r));
await load();
} catch (e) {
@@ -173,8 +174,8 @@ function AttendancePageInner() {
</label>
)}
<div className="ml-auto flex flex-wrap gap-2">
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
</div>
</div>
@@ -191,9 +192,9 @@ function AttendancePageInner() {
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
<Award className="w-4 h-4 text-brand-600" />
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
<button type="button" disabled={busy} onClick={() => post(`/events/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/events/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/v2/events/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
</div>
)}
@@ -248,12 +249,12 @@ function AttendancePageInner() {
{r.certificate && !r.certificate.revokedAt ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700">{r.certificate.code}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/v2/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
</div>
) : canManage ? (
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
</button>
) : <span className="text-xs text-gray-400">—</span>}
@@ -3,13 +3,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
@@ -118,7 +119,7 @@ export default function BundlesPage() {
if (!token || !eventId) { setBundles([]); return; }
setFetching(true);
try {
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
setBundles(await (apiV2Data("get", "/events/{id}/bundles", { params: { id: eventId }, authToken: token }) as Promise<EventBundle[]>));
} catch (e) {
setError(errorMessage(e) || "Failed to load bundles");
} finally {
@@ -130,7 +131,7 @@ export default function BundlesPage() {
const toggleActive = async (b: EventBundle) => {
try {
await apiFetch(`/api/bundles/${b.id}`, { method: "PUT", authToken: token!, body: { isActive: !b.isActive } });
await apiV2Data("patch", "/bundles/{id}", { params: { id: b.id }, authToken: token, body: { isActive: !b.isActive } });
setInfo(`${b.name} turned ${b.isActive ? "off" : "on"}.`);
load();
} catch (e) {
@@ -141,7 +142,7 @@ export default function BundlesPage() {
const remove = async (b: EventBundle) => {
if (!window.confirm(`Delete "${b.name}"?`)) return;
try {
const res = await apiFetch<{ message: string }>(`/api/bundles/${b.id}`, { method: "DELETE", authToken: token! });
const res = await (apiV2Data("delete", "/bundles/{id}", { params: { id: b.id }, authToken: token }) as Promise<{ message: string }>);
setInfo(res?.message || "Bundle deleted");
load();
} catch (e) {
@@ -357,10 +358,10 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
isActive: d.isActive,
};
if (bundleId) {
await apiFetch(`/api/bundles/${bundleId}`, { method: "PUT", authToken: token, body });
await apiV2Data("patch", "/bundles/{id}", { params: { id: bundleId }, authToken: token, body: body as never });
onSaved(`${body.name} saved.`);
} else {
await apiFetch(`/api/bundles/event/${event.id}`, { method: "POST", authToken: token, body });
await apiV2Data("post", "/events/{id}/bundles", { params: { id: event.id }, authToken: token, body: body as never });
onSaved(`${body.name} created.`);
}
} catch (err) {
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, ConsentRow, MedicalRow } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
@@ -38,7 +39,8 @@ export default function ConsentTrackerPage() {
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
type ConsentData = { event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] };
const [data, setData] = useState<ConsentData | null>(null);
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
@@ -60,7 +62,7 @@ export default function ConsentTrackerPage() {
if (!token || !eventId) return;
setBusy(true);
try {
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
setData(await (apiV2Data("get", "/events/{id}/consent", { params: { id: eventId }, authToken: token }) as Promise<ConsentData>));
} catch (e) {
setError(errorMessage(e) || "Failed to load consent status");
} finally {
@@ -79,10 +81,10 @@ export default function ConsentTrackerPage() {
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
const act = async (fn: () => Promise<{ message: string }>) => {
const act = async (fn: () => Promise<unknown>) => {
setBusy(true);
try {
const res = await fn();
const res = (await fn()) as { message: string };
setInfo(res.message);
await load();
} catch (e) {
@@ -92,14 +94,14 @@ export default function ConsentTrackerPage() {
}
};
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
const resendOne = (r: ConsentRow) => act(() => apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: r.request!.id }, authToken: token, body: {} }));
const resendAll = () => act(() => apiV2Data("post", "/events/{id}/consent/resend-pending", { params: { id: eventId }, authToken: token, body: {} }));
const rerequest = () => act(() => apiV2Data("post", "/events/{id}/consent/rerequest", { params: { id: eventId }, authToken: token, body: {} }));
// Printable list for camp leaders. Opening it is recorded in the audit log.
const printMedical = async () => {
try {
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
const res = await (apiV2Data("get", "/events/{id}/consent/medical", { params: { id: eventId }, authToken: token }) as Promise<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>);
const w = window.open("", "_blank");
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
const rowsHtml = res.rows.map(r => `
@@ -3,11 +3,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
@@ -163,7 +164,7 @@ export default function CouponsPage() {
setFetching(true);
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
(apiV2Data("get", "/coupons", { authToken: token }) as Promise<Coupon[]>),
fetchManagedEvents<EventLite>(token, { past: true }),
]);
setCoupons(c || []);
@@ -191,7 +192,7 @@ export default function CouponsPage() {
const toggleActive = async (c: Coupon) => {
try {
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
await apiV2Data("patch", "/coupons/{id}", { params: { id: c.id }, authToken: token, body: { active: !c.active } });
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
load();
} catch (e) {
@@ -201,7 +202,7 @@ export default function CouponsPage() {
const openUses = async (c: Coupon) => {
try {
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
const full = await (apiV2Data("get", "/coupons/{id}", { params: { id: c.id }, authToken: token }) as Promise<Coupon & { redemptions: Redemption[] }>);
setViewing({ coupon: c, redemptions: full.redemptions || [] });
} catch (e) {
setError(errorMessage(e) || "Failed to load uses");
@@ -445,7 +446,7 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: { search: userQuery.trim(), limit: "8" }, authToken: token }) as Promise<{ data: { id: string; name: string; email: string }[] }>);
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
@@ -473,10 +474,10 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
try {
const body = bodyFromDraft(d);
if (couponId) {
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
await apiV2Data("patch", "/coupons/{id}", { params: { id: couponId }, authToken: token, body: body as never });
onSaved(`${body.code.toUpperCase()} saved.`);
} else {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
await apiV2Data("post", "/coupons", { authToken: token, body: body as never });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err) {
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -13,6 +13,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -279,7 +280,7 @@ function EmailAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "email" }, authToken: token }) as Promise<{jobs: ScheduledJob[]}>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch {
// ignore here; surfaces via UI when tab open
@@ -314,7 +315,7 @@ function EmailAttendeesPageInner() {
body.html = '';
}
}
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body });
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
setInfo('Scheduled email updated.');
setEditing(null);
loadScheduled();
@@ -328,7 +329,7 @@ function EmailAttendeesPageInner() {
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError('Not authenticated'); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
setInfo('Scheduled email removed.');
loadScheduled();
} catch (e) {
@@ -390,7 +391,7 @@ function EmailAttendeesPageInner() {
setAttendees([]);
setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r) => {
const u: User | undefined = r?.user;
@@ -431,7 +432,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -472,7 +473,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
@@ -669,7 +670,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) {
setInfo('Email scheduled. It will be sent around the specified time.');
} else {
@@ -778,7 +779,7 @@ Jane Doe <jane@example.com>
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined, kind: broadcastKind };
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
const res = await (apiV2Data("post", "/broadcasts/email/preview", { authToken: token, body }) as Promise<EmailSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -795,7 +796,7 @@ Jane Doe <jane@example.com>
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/send", { authToken: token, body: payload }) as Promise<EmailSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ''}`);
// Reset broadcast form
resetBroadcastForm();
@@ -813,7 +814,7 @@ Jane Doe <jane@example.com>
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/schedule", { authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
// Reset broadcast form
resetBroadcastForm();
@@ -1084,7 +1085,7 @@ With gratitude`);
jobs.push(payload);
}
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
const res = await (apiV2Data("post", "/automations/schedule", { authToken: token, body: { eventId: autoEventId, jobs } }) as Promise<{ message?: string }>);
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
// Reset to defaults (keep event selection)
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
@@ -3,13 +3,14 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventOption } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { createTicketOption, updateTicketOption, deleteTicketOption } from "@/lib/eventSetup";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -160,11 +161,7 @@ function EventOptionsContent() {
if (!newOpt.name) { setError("Option name is required"); return; }
const priceNum = parseFloat(newOpt.price || "0");
try {
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
method: "POST",
authToken: token,
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
});
await createTicketOption(token, selectedEventId, { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket });
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
@@ -177,11 +174,7 @@ function EventOptionsContent() {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "PUT",
authToken: token,
body: patch
});
await updateTicketOption(token, opt.id, patch);
setInfo("Option updated");
await loadEvents();
} catch (e) {
@@ -193,10 +186,7 @@ function EventOptionsContent() {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "DELETE",
authToken: token,
});
await deleteTicketOption(token, opt.id);
setInfo("Option deleted");
await loadEvents();
} catch (e) {
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -22,7 +22,8 @@ import {
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { createTicketOption, updateTicketOption, listSections, createSection, updateSection, deleteSection, listAttachments, uploadAttachment, deleteAttachment, uploadImage } from "@/lib/eventSetup";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -61,7 +62,7 @@ type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "par
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
type FieldDef = EventFormDef["fields"][number];
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
// What PATCH /api/v2/ticket-options/{id} takes as earlyBirdTiers (variantId null = option-level).
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
@@ -120,10 +121,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("image", file);
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
const uploaded = res?.url || res?.path;
const uploaded = await uploadImage(token, "event-image", file);
if (uploaded) onUploaded(uploaded);
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
@@ -506,8 +504,8 @@ function SectionsManager({ eventId }: { eventId: string }) {
setLoading(true);
try {
const [secList, evFull] = await Promise.all([
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
apiFetch<Event>(`/api/events/${eventId}`)
listSections<Section>(token),
fetchEvent<Event>(eventId)
]);
setSections((secList || []).filter(s => s.eventId === eventId));
setEventOptions(evFull?.eventOptions || []);
@@ -517,17 +515,17 @@ function SectionsManager({ eventId }: { eventId: string }) {
const create = async () => {
if (!newName.trim() || !token) return;
await apiFetch("/api/sections", { method: "POST", authToken: token, body: { eventId, name: newName.trim(), allowedOptionIds: newOptionIds } });
await createSection(token, { eventId, name: newName.trim(), allowedOptionIds: newOptionIds });
setNewName(""); setNewOptionIds([]); load();
};
const del = async (id: string) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "DELETE", authToken: token });
await deleteSection(token, id);
load();
};
const update = async (id: string, name: string, optionIds: string[]) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "PUT", authToken: token, body: { name, allowedOptionIds: optionIds } });
await updateSection(token, id, { name, allowedOptionIds: optionIds });
load();
};
@@ -595,8 +593,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
const ref = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
setItems(Array.isArray(res) ? res : []);
setItems(await listAttachments(eventId));
}, [eventId]);
useEffect(() => { load(); }, [load]);
@@ -605,16 +602,14 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${eventId}/attachments`, { method: "POST", body: fd, authToken: token });
await uploadAttachment(token, eventId, file);
await load();
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
};
const del = async (id: string) => {
if (!token || !confirm("Delete this attachment?")) return;
await apiFetch(`/api/events/${eventId}/attachments/${id}`, { method: "DELETE", authToken: token });
await deleteAttachment(token, eventId, id);
await load();
};
@@ -694,7 +689,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
const res = await (apiV2Data("get", "/users", { query: { search: search.trim(), limit: "10" }, authToken: token }) as Promise<Paginated<NotifyUser>>);
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
@@ -964,7 +959,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
useEffect(() => {
if (!token) return;
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => setSeriesList(Array.isArray(l) ? l : [])).catch(() => {});
(apiV2Data("get", "/series", { authToken: token }) as Promise<EventSeries[]>).then(l => setSeriesList(l)).catch(() => {});
}, [token]);
// ── multi-day schedule + attendance settings ──
@@ -1040,7 +1035,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
useEffect(() => {
if (!cloned || !source?.id || !token) return;
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
listSections<Section>(token, source.id).then(list => {
const drafts: SectionDraft[] = [];
for (const sec of list || []) {
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
@@ -1065,7 +1060,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
fetchEvent<Event>(ev.id).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
@@ -1082,7 +1077,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
useEffect(() => {
if (!feedbackFrom || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
(apiV2Data("get", "/events/{id}/feedback", { params: { id: feedbackFrom }, authToken: token }) as Promise<FeedbackFormResponse | null>).then(f => {
if (!f) return;
setFbEnabled(true);
if (cloning) {
@@ -1116,8 +1111,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
};
const saveFeedback = async (eventId: string) => {
if (fbEnabled) {
await apiFetch(`/api/feedback/event/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2Data("put", "/events/{id}/feedback", {
params: { id: eventId },
authToken: token,
body: {
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
@@ -1125,7 +1121,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
},
});
} else if (fbMeta) {
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/events/{id}/feedback", { params: { id: eventId }, authToken: token });
}
};
@@ -1205,10 +1201,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
});
const updated = await updateTicketOption(token, opt.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload });
// Build variant tiers with confirmed IDs from response
const serverVariants = updated?.variants || [];
@@ -1224,10 +1217,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
if (allTiers.length > 0 || opt.earlyBirdTiers.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, opt.id, { earlyBirdTiers: allTiers });
}
// Handle form save (edit only)
@@ -1237,15 +1227,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
});
} else {
// New option added during edit
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
if (created?.id) {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined,
body: { variants: variantsPayload }
});
const updated = await updateTicketOption(token, created.id, { variants: variantsPayload });
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
@@ -1258,9 +1242,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, created.id, { earlyBirdTiers: allTiers });
}
}
}
@@ -1268,7 +1250,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
} else {
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
let createdEvent: Event | null = null;
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
try { createdEvent = await fetchEvent<Event>(eventId); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
const uiMainIdx = options.findIndex(o => o.isMainTicket);
@@ -1284,16 +1266,10 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (i === uiMainIdx && existingMain) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
await updateTicketOption(token, existingMain.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
optId = existingMain.id;
} else {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
optId = created?.id;
}
@@ -1305,9 +1281,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
});
const updated = await updateTicketOption(token, optId, { variants: variantsPayload });
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
@@ -1322,9 +1296,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, optId, { earlyBirdTiers: allTiers });
}
}
}
@@ -1337,10 +1309,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
.map(idx => createdOptions[idx]?.id)
.filter(Boolean);
try {
await apiFetch("/api/sections", {
method: "POST", authToken: token || undefined,
body: { eventId, name: sec.name.trim(), allowedOptionIds: optionIds }
});
await createSection(token, { eventId, name: sec.name.trim(), allowedOptionIds: optionIds as string[] });
} catch {}
}
};
@@ -1430,14 +1399,12 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
await apiV2("post", "/events/{id}/copy-from", { params: { id: created.id }, authToken: token, body: { fromEventId: source!.id } });
}
// Fetch event to get final option IDs for sections
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const fullEvent = await fetchEvent<Event>(created.id).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
// Upload staged attachments
for (const file of stagedFiles) {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${created.id}/attachments`, { method: "POST", body: fd, authToken: token || undefined });
await uploadAttachment(token, created.id, file);
}
// Save staged notify recipients
if (notifyRecipientDrafts.length > 0) {
@@ -1727,7 +1694,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
uploadButton={(onUploaded) => <UploadImageButton onUploaded={onUploaded} label="Upload" />}
onPreview={async () => {
if (!token) return;
openBlob(await fetchApiFile(API_BASE, token, "/api/attendance/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
openBlob(await fetchApiFile(API_BASE, token, "/api/v2/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
}}
/>
)}
@@ -2090,7 +2057,7 @@ export default function ManageEventsPage() {
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
setEditingEvent(full);
} catch { setEditingEvent(ev); }
setModalMode("edit");
@@ -2099,7 +2066,7 @@ export default function ManageEventsPage() {
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
const openCopy = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
setEditingEvent(null);
setCopyFrom(full);
setModalMode("create");
@@ -4,14 +4,14 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
@@ -221,8 +221,7 @@ export default function FormsBrowserPage() {
if (userId.trim()) qs.set("userId", userId.trim());
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
if (append && nextCursor) qs.set("cursor", nextCursor);
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
const res = await (apiV2Data("get", "/form-responses", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ items: FormResponse[]; nextCursor?: string }>);
const newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
@@ -308,7 +307,7 @@ export default function FormsBrowserPage() {
if (!eventId) return;
try {
setFormLoading(true);
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
const evFull = await fetchEvent<Event>(eventId);
setFormDef(toFormDef(evFull?.form));
setFormLoadedFor(eventId);
} catch (e) {
@@ -350,7 +349,7 @@ export default function FormsBrowserPage() {
if (!token || !evId) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: evId }, authToken: token }) as Promise<Registration[]>);
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
setFillRegistrations(sorted);
} catch {
@@ -374,13 +373,13 @@ export default function FormsBrowserPage() {
(async () => {
if (mode !== 'fill' || !fillRegistrationId) return;
try {
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: fillRegistrationId }, authToken: token }) as Promise<Registration>);
setFillRegistration(reg);
if (reg?.event?.id) {
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const evFull = await fetchEvent<Event>(reg.event.id);
setFillForm(toFormDef(evFull?.form));
}
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration/form');
@@ -435,11 +434,13 @@ export default function FormsBrowserPage() {
if (v == null || String(v).trim() === '') throw new Error(`Response #${i+1}: Missing answer for a required field`);
}
}
await apiFetch(`/api/registrations/${encodeURIComponent(fillRegistrationId)}/forms/responses`, {
method: 'PUT', authToken: token, body: { responses: editableResponses }
await apiV2Data("put", "/registrations/{id}/form-responses", {
params: { id: fillRegistrationId },
authToken: token,
body: { responses: editableResponses },
});
setInfo('Responses saved');
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to save responses');
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
@@ -34,13 +34,13 @@ export default function KidsSetupPage() {
useEffect(() => {
if (!token) return;
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
(apiV2Data("get", "/kids/setup-events", { authToken: token }) as Promise<SetupEvent[]>).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
const data = await (apiV2Data("get", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>);
setS(data);
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
@@ -64,16 +64,16 @@ export default function KidsSetupPage() {
setBusy(false);
}
};
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiV2Data("put", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token, body }), ok);
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
const addRoom = () => s?.event.kidsRoomSetId && run(
() => apiFetch(`/api/allocations/sets/${s.event.kidsRoomSetId}/groups`, { method: "POST", authToken: token, body: roomBody(newRoom) }),
() => apiV2Data("post", "/allocation-sets/{id}/groups", { params: { id: s.event.kidsRoomSetId! }, authToken: token, body: roomBody(newRoom) }),
"Room added.",
).then(ok => { if (ok) setNewRoom({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" }); });
const saveRoom = (id: string) => run(() => apiFetch(`/api/allocations/groups/${id}`, { method: "PUT", authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiFetch(`/api/allocations/groups/${r.id}`, { method: "DELETE", authToken: token }), "Room deleted.");
const saveRoom = (id: string) => run(() => apiV2Data("patch", "/allocation-groups/{id}", { params: { id: id }, authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiV2Data("delete", "/allocation-groups/{id}", { params: { id: r.id }, authToken: token }), "Room deleted.");
const roomRow = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }, set: (v: typeof d) => void) => (
<>
@@ -4,12 +4,13 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
@@ -39,19 +40,18 @@ export default function ManualRegistrationPage() {
setBusy(true);
setError(null);
try {
const res = await apiFetch<Registration>("/api/registrations/manual", {
method: "POST",
const res = await (apiV2Data("post", "/registrations/manual", {
authToken: token,
body: {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
},
});
}) as Promise<Registration>);
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(eventId);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
@@ -154,8 +154,10 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
setError(null); setInfo(null);
const payload: { answers: Record<string, string> }[] = [];
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
method: 'POST', authToken: token, body: { responses: payload }
await apiV2Data("post", "/registrations/{id}/form-responses", {
params: { id: registration.id },
authToken: token,
body: { responses: payload },
});
setInfo('Attendee forms submitted successfully.');
} catch (e) {
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents, fetchEvent, fetchAllRegistrations } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -17,6 +17,7 @@ import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -24,7 +25,7 @@ function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
}
// Effective unit price for an existing registration option (as returned by /api/registrations).
// Effective unit price for an existing registration option (as returned by /api/v2/registrations).
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
// back to a live early-bird calculation for legacy rows without a snapshot.
function optionUnitPrice(opt: RegistrationOption): number {
@@ -103,7 +104,7 @@ export default function ManualRegistrationPage() {
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<Registration[]>("/api/registrations", { authToken: token })
fetchAllRegistrations(token)
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
@@ -163,8 +164,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch("/api/payments", {
method: "POST",
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -173,14 +173,14 @@ export default function ManualRegistrationPage() {
registrationId: payRegistrationId,
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
}
},
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const regs = await fetchAllRegistrations(token);
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e) {
@@ -277,7 +277,7 @@ export default function ManualRegistrationPage() {
useEffect(() => {
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
fetchEvent<Event>(selectedEventId, { authToken: token || undefined })
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
return () => { cancelled = true; };
@@ -346,8 +346,7 @@ export default function ManualRegistrationPage() {
const hasEmail = !!guest.email.trim();
const hasPhone = !!guest.phoneNumber.trim();
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
method: "POST",
const res = await (apiV2Data("post", "/registrations/manual", {
authToken: token,
body: {
eventId: selectedEventId,
@@ -358,8 +357,8 @@ export default function ManualRegistrationPage() {
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
: {}),
}
});
},
}) as Promise<Registration & { couponError?: string }>);
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
@@ -3,13 +3,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
@@ -55,8 +56,8 @@ export default function OfflineScansPage() {
if (!token || !eventId) return;
try {
const [l, r] = await Promise.all([
apiFetch<Lock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token }),
apiFetch<Report[]>(`/api/scan-locks/reports?eventId=${encodeURIComponent(eventId)}${onlyOpen ? "&unreviewed=true" : ""}`, { authToken: token }),
(apiV2Data("get", "/scan-locks", { query: { eventId: eventId }, authToken: token }) as Promise<Lock[]>),
(apiV2Data("get", "/scan-locks/reports", { query: { eventId, ...(onlyOpen ? { unreviewed: "true" } : {}) }, authToken: token }) as Promise<Report[]>),
]);
setLocks(l);
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
@@ -68,14 +69,14 @@ export default function OfflineScansPage() {
const forceRelease = async (l: Lock) => {
try {
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
const res = await (apiV2Data("post", "/scan-locks/{id}/force-release", { params: { id: l.id }, authToken: token, body: {} }) as Promise<{ message: string }>);
setInfo(res.message);
load();
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
const review = async (r: Report) => {
try {
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
await apiV2Data("post", "/scan-locks/reports/{id}/review", { params: { id: r.id }, authToken: token, body: {} });
load();
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/type
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -91,9 +91,9 @@ export default function SupervisorDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
(apiV2Data("get", "/stats/supervisor", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchAllPayments, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
@@ -14,6 +14,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -193,7 +194,7 @@ function PaymentsContent() {
setLoadingList(true);
const start = new Date(); start.setHours(0, 0, 0, 0);
const [recent, today, donations] = await Promise.all([
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
(apiV2Data("get", "/payments", { query: { page: "1", limit: "40" }, authToken: token }) as Promise<{ data: Payment[] }>),
fetchAllPayments(token, { from: start.toISOString() }),
fetchAllPayments(token, { donations: "1" }),
]);
@@ -209,14 +210,14 @@ function PaymentsContent() {
useEffect(() => { loadPayments(); }, [loadPayments]);
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/v2/registrations
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
// the chosen person's own registrations (including paid ones) when needed.
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
const regs = await fetchAllRegistrations(token, { unpaid: true });
const list = Array.isArray(regs) ? regs : [];
setRegOutstanding(computeOutstanding(list));
// Sort by createdAt desc
@@ -301,17 +302,16 @@ function PaymentsContent() {
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
setLinkGenerating(true);
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
method: "POST",
const res = await (apiV2Data("post", "/registrations/{id}/payment-link", {
params: { id: linkRegistrationId },
authToken: token,
body: {
registrationId: linkRegistrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
}) as Promise<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>);
if (res.priceUpdated) {
setError(res.message || "Pricing has changed for this registration; please re-check the amount.");
return;
@@ -342,10 +342,10 @@ function PaymentsContent() {
setError(null); setInfo(null);
try {
setLinkSending(channel);
await apiFetch("/api/payments/checkout/send", {
method: "POST",
await apiV2Data("post", "/registrations/{id}/payment-link/send", {
params: { id: linkResult.registrationId },
authToken: token,
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
body: { redirectUrl: linkResult.redirectUrl, channel },
});
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
} catch (e) {
@@ -405,8 +405,7 @@ function PaymentsContent() {
}
try {
setSubmitting(true);
await apiFetch("/api/payments", {
method: "POST",
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -416,7 +415,7 @@ function PaymentsContent() {
eventId: eventId || undefined,
isDonation: !!isDonation,
paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined,
}
},
});
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
@@ -447,7 +446,7 @@ function PaymentsContent() {
if (!token) return;
try {
setTxLoading(true);
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
const res = await (apiV2Data("get", "/provider-transactions/unreconciled", { authToken: token }) as Promise<{ data?: ProviderTx[] } | ProviderTx[]>);
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setProviderTxs(items);
} catch {
@@ -486,18 +485,18 @@ function PaymentsContent() {
setReconcileForm(prev => ({ ...prev, submitting: true }));
if (type === 'reg') {
if (!registrationId) { setError('Please select a registration'); return; }
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
params: { id: txId },
authToken: token,
body: { registrationId }
body: { registrationId },
});
setInfo('Reconciled to registration');
} else {
if (!eventId) { setError('Please select an event'); return; }
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
params: { id: txId },
authToken: token,
body: { eventId, userId: userId || undefined }
body: { eventId, userId: userId || undefined },
});
setInfo('Reconciled as donation');
}
@@ -617,7 +616,7 @@ function PaymentsContent() {
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
if (!ok) return;
try {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
await apiV2Data("post", "/provider-transactions/{id}/ignore", { params: { id: tx.id }, authToken: token, body: {} });
setInfo('Transaction ignored');
await loadUnreconciled();
} catch (e) {
@@ -891,7 +890,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
setLoadingUser(true);
Promise.all([
fetchAllPayments(token, { userId }),
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
fetchAllRegistrations(token, { userId }),
]).then(([p, r]) => {
if (cancelled) return;
setPayments(p);
@@ -935,9 +934,8 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
if (!method) { setErr('Select a refund method'); return; }
try {
setSubmitting(true);
await apiFetch('/api/payments/refund', {
method: 'POST',
authToken: token!,
await apiV2Data("post", "/payments/refunds", {
authToken: token,
body: {
userId,
amount: amt,
@@ -945,7 +943,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
paymentId: target === 'payment' ? paymentId : undefined,
registrationId: target === 'registration' ? registrationId : undefined,
reason: reason || undefined
}
},
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
@@ -1083,10 +1081,9 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
if (!ok) return;
try {
setUnassigningId(leg.id);
await apiFetch('/api/payments/unassign-donation', {
method: 'POST',
await apiV2Data("post", "/payments/donations/unassign", {
authToken: token,
body: { legId: leg.id }
body: { legId: leg.id },
});
await onDone();
} catch (e) {
@@ -1266,8 +1263,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; }
try {
setAssigning(true);
await apiFetch('/api/payments/assign-donation', {
method: 'PUT',
await apiV2Data("post", "/payments/donations/assign", {
authToken: token,
body: { paymentId, registrationId, amount: amountNum }
});
@@ -5,12 +5,12 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Layers, ExternalLink, Users, BarChart3 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { SeasonPassesPanel } from "@/components/series/SeasonPassesPanel";
import type { EventSeries } from "@/types";
import { apiV2Data } from "@/lib/apiV2";
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
const blank: Draft = { name: "", slug: "", description: "", picture: "" };
@@ -69,7 +69,7 @@ export default function SeriesPage() {
const load = useCallback(async () => {
if (!token) return;
try {
setList(await apiFetch<EventSeries[]>("/api/series", { authToken: token }));
setList(await (apiV2Data("get", "/series", { authToken: token }) as Promise<EventSeries[]>));
} catch (e) {
setError(errorMessage(e) || "Couldn't load the series");
}
@@ -81,7 +81,8 @@ export default function SeriesPage() {
setSaving(true); setError(null); setNotice(null);
try {
const body = { name: draft.name, slug: draft.slug || undefined, description: draft.description, picture: draft.picture };
await apiFetch(draft.id ? `/api/series/${draft.id}` : "/api/series", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
if (draft.id) await apiV2Data("patch", "/series/{id}", { params: { id: draft.id }, authToken: token, body: body as never });
else await apiV2Data("post", "/series", { authToken: token, body: body as never });
setNotice(draft.id ? "Series saved." : `"${draft.name.trim()}" created — choose it in the event editor to add events.`);
setDraft(null);
await load();
@@ -96,7 +97,7 @@ export default function SeriesPage() {
if (!window.confirm(`Delete "${s.name}"? Its ${s.eventCount} event${s.eventCount === 1 ? "" : "s"} stay, just without a series, and ${s.followers} follower${s.followers === 1 ? "" : "s"} won't hear about new ones.`)) return;
setError(null); setNotice(null);
try {
await apiFetch(`/api/series/${s.id}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/series/{id}", { params: { id: s.id }, authToken: token });
setNotice(`"${s.name}" deleted.`);
await load();
} catch (e) {
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Settings = {
event: { id: string; title: string; volunteerOptionId: string | null };
@@ -39,7 +39,7 @@ export default function VolunteerRostersPage() {
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
(apiV2Data("get", "/volunteering/events", { query: { all: "1" }, authToken: token }) as Promise<RosterEvent[]>)
.then(list => {
setEvents(list);
const wanted = new URLSearchParams(window.location.search).get("event");
@@ -51,7 +51,7 @@ export default function VolunteerRostersPage() {
const loadSettings = useCallback(async () => {
if (!token || !eventId) { setSettings(null); return; }
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
setSettings(await (apiV2Data("get", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
}
@@ -61,7 +61,7 @@ export default function VolunteerRostersPage() {
useEffect(() => {
if (!token || tab !== "hours") return;
setHours(null);
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
(apiV2Data("get", "/volunteering/hours", { query: { eventId: !hoursAll && eventId ? eventId : undefined }, authToken: token }) as Promise<HoursRow[]>)
.then(setHours)
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
}, [token, tab, hoursAll, eventId, setMsg]);
@@ -69,7 +69,7 @@ export default function VolunteerRostersPage() {
const saveTicket = async (optionId: string | null, ok: string) => {
setBusy(true);
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
setSettings(await (apiV2Data("put", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token, body: { optionId } }) as Promise<Settings>));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });

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