Compare commits

...
Author SHA1 Message Date
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
114 changed files with 36662 additions and 519 deletions
+76
View File
@@ -7,6 +7,82 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+23
View File
@@ -12,6 +12,29 @@ 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.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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.11",
"version": "1.51.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.11",
"version": "1.51.15",
"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.11",
"version": "1.51.15",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+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) {
+184
View File
@@ -46,6 +46,190 @@ 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.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' },
+4
View File
@@ -15,6 +15,10 @@ 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';
+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 });
@@ -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,
},
};
},
});
+1 -1
View File
@@ -115,7 +115,7 @@ v1Route({
});
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\x27s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
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,
});
+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: 'NOT_FOUND when no account has that email (as v1 does).', access: 'public', 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 });
+4
View File
@@ -78,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;
}
+22 -3
View File
@@ -9,13 +9,16 @@
* 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 */
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown };
/** `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). */
@@ -72,6 +75,21 @@ class RecordingResponse extends Writable {
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;
@@ -86,7 +104,7 @@ class RecordingResponse extends Writable {
*/
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'] as const) {
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();
@@ -97,7 +115,8 @@ export async function runV1(handlers: RequestHandler | RequestHandler[], req: Re
advanced = true;
if (err) return reject(err);
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
else resolve();
// 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);
});
+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);
});
+2
View File
@@ -51,7 +51,9 @@ jest.mock('../src/utils/campusScope', () => ({
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)])));
+25
View File
@@ -72,4 +72,29 @@ describe('v2 routes answered by v1 handlers', () => {
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");
});
});
+9 -6
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.11** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-30, after **1.51.15** (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).
@@ -17,6 +17,10 @@ encrypted settings unreadable or break existing installs.
| 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.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()` |
@@ -71,10 +75,9 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.12 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
1. **1.51.16: incoming webhooks and links in sent messages** get v2 URLs (every call the app makes
is on v2 since 1.51.15), 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
@@ -516,7 +519,7 @@ a test server (both done 2026-10-28).
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
`middleware`; check `req.multerError`/`req.file` in the handler).
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
email/WhatsApp senders (messaging, 1.51.14).
email/WhatsApp senders — all on v2 since 1.51.14 (`v1Route`).
## Setting events up on v2 (1.51.7)
+5 -3
View File
@@ -42,8 +42,10 @@ pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **even
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2** and 1.51.11 **money on v2** shipped.
**Next: 1.51.12 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
**privacy and admin on v2** shipped — every call the app makes is on v2. **Next: 1.51.16 (incoming
webhooks and links in sent messages), then 2.0.0.**
**Repositories:**
@@ -1012,7 +1014,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.11 ✅, 1.51.12 – 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` | 2.0 can then remove v1 |
| 11b | 1.51.4 ✅ – 1.51.15 ✅, 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` | 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/`),
+85 -2
View File
@@ -90,8 +90,9 @@ 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"`);
// Endpoints with no v2 version yet (incoming webhooks, until 1.51.16) say nothing.
expect((await request.post(`${API}/api/webhooks/whatsapp`, { data: {} })).headers()["deprecation"]).toBeUndefined();
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
@@ -240,6 +241,88 @@ test.describe("API v2", () => {
}
});
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 });
// As v1: an unknown email is NOT_FOUND.
const forgot = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
expect((await forgot.json()).error.code).toBe("NOT_FOUND");
});
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);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.11",
"version": "1.51.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.11",
"version": "1.51.15",
"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.11",
"version": "1.51.15",
"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
@@ -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);
+1 -1
View File
@@ -42,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 };
}
@@ -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,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);
@@ -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, 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";
@@ -164,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 || "");
@@ -212,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) {
@@ -251,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,
@@ -283,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");
@@ -325,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();
@@ -351,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(),
@@ -884,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
@@ -898,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 {
@@ -921,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);
@@ -945,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();
@@ -970,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) {
@@ -988,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) {
@@ -1002,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) {
@@ -1023,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);
};
@@ -1257,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,7 @@ 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
@@ -330,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]);
@@ -338,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, fetchEvent } 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";
@@ -885,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([]);
@@ -919,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([]);
@@ -1043,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 {
@@ -1536,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,
@@ -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,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 } 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";
@@ -446,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);
@@ -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";
@@ -280,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
@@ -315,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();
@@ -329,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) {
@@ -432,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));
@@ -473,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
@@ -670,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 {
@@ -779,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));
@@ -796,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();
@@ -814,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();
@@ -1085,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);
@@ -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, fetchEvent } 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";
@@ -689,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([]);
@@ -1694,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 } }));
}}
/>
)}
@@ -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) => (
<>
@@ -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);
@@ -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" });
@@ -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, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -249,8 +249,9 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
await apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", {
params: { id: autoEventId },
authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
@@ -462,7 +463,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload(true) }) as Promise<MessageSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -482,7 +483,7 @@ function WhatsAppAttendeesPageInner() {
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload() }) as Promise<MessageSendResult>);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
@@ -501,7 +502,7 @@ function WhatsAppAttendeesPageInner() {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, kind: messageKind, scheduledAt: whenIso,
};
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<MessageSendResult>);
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
@@ -554,7 +555,7 @@ function WhatsAppAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "whatsapp" }, authToken: token }) as Promise<{ jobs: ScheduledJob[] }>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch { } finally { setLoadingScheduled(false); }
}, [token]);
@@ -569,7 +570,7 @@ function WhatsAppAttendeesPageInner() {
const body: { scheduledAt?: string; message?: string } = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.message = editMessage;
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 message updated.");
setEditing(null);
loadScheduled();
@@ -579,7 +580,7 @@ function WhatsAppAttendeesPageInner() {
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 message removed.");
loadScheduled();
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
@@ -828,7 +829,7 @@ function WhatsAppAttendeesPageInner() {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/preview", { authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -840,7 +841,7 @@ function WhatsAppAttendeesPageInner() {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/send", { authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ""}`);
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
@@ -852,7 +853,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/schedule", { authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
@@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
@@ -23,7 +22,7 @@ export default function DonatePage() {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events");
const evs = await (apiV2Data("get", "/events") as Promise<Event[]>);
setEvents(evs);
if (evs.length > 0) setEventId(evs[0].id);
} 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 { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { QrImage } from "@/components/shared/QrImage";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { FAMILY_QR_PREFIX, type KidsAdult, type KidsChild, type KidsFamily } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
type ActiveCode = { code: string; qr: string; event: { id: string; title: string }; children: { name: string; room: string | null; checkedInAt: string }[] };
type Mine = { family: KidsFamily | null; qrToken: string | null; codes: ActiveCode[]; pages: { id: string; message: string; createdAt: string }[] };
@@ -31,7 +31,7 @@ export default function MyKidsPage() {
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/kids/me", { authToken: token }));
setData(await (apiV2Data("get", "/me/family", { authToken: token }) as Promise<Mine>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your family" });
}
@@ -56,22 +56,22 @@ export default function MyKidsPage() {
}
};
const startFamily = () => call(() => apiFetch("/api/kids/me", { method: "POST", authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const startFamily = () => call(() => apiV2Data("post", "/me/family", { authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const saveChild = (child: KidsChild | null, input: ChildInput) => call(
() => apiFetch(child ? `/api/kids/me/children/${child.id}` : "/api/kids/me/children", { method: child ? "PUT" : "POST", authToken: token, body: input }),
() => (child ? apiV2Data("put", "/me/family/children/{id}", { params: { id: child.id }, authToken: token, body: input }) : apiV2Data("post", "/me/family/children", { authToken: token, body: input })),
child ? "Saved." : "Child added.",
).then(ok => { if (ok) setEditing(null); });
const removeChild = (child: KidsChild) => {
if (!confirm(`Remove ${child.firstName}? Their details are deleted.`)) return;
call(() => apiFetch(`/api/kids/me/children/${child.id}`, { method: "DELETE", authToken: token }), "Removed.");
call(() => apiV2Data("delete", "/me/family/children/{id}", { params: { id: child.id }, authToken: token }), "Removed.");
};
const saveAdult = () => adultForm && call(
() => apiFetch(adultForm.id ? `/api/kids/me/adults/${adultForm.id}` : "/api/kids/me/adults", { method: adultForm.id ? "PUT" : "POST", authToken: token, body: adultForm }),
() => (adultForm.id ? apiV2Data("put", "/me/family/adults/{id}", { params: { id: adultForm.id }, authToken: token, body: adultForm }) : apiV2Data("post", "/me/family/adults", { authToken: token, body: adultForm })),
"Saved.",
).then(ok => { if (ok) setAdultForm(null); });
const removeAdult = (a: KidsAdult) => {
if (!confirm(`Remove ${a.name} from your authorised adults?`)) return;
call(() => apiFetch(`/api/kids/me/adults/${a.id}`, { method: "DELETE", authToken: token }), "Removed.");
call(() => apiV2Data("delete", "/me/family/adults/{id}", { params: { id: a.id }, authToken: token }), "Removed.");
};
const share = async (c: ActiveCode) => {
const text = `Pickup code for ${c.children.map(k => k.name.split(" ")[0]).join(", ")} at ${c.event.title}: ${c.code}`;
+10 -12
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
import { createFullPaymentCheckout, fetchEvent } from "@/lib/api";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
@@ -125,7 +125,7 @@ export default function UserDashboardPage() {
// their payments had already resolved.
const [myRegs, myTicks] = await Promise.all([
(apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>),
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
(apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>),
]);
setRegistrations(myRegs);
setTickets(myTicks);
@@ -230,11 +230,10 @@ export default function UserDashboardPage() {
// Open loading dialog immediately
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
const res = await (apiV2Data("post", "/me/tickets/email", {
body: { ticketIds },
authToken: token,
});
}) as Promise<{ message?: string }>);
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
@@ -250,11 +249,10 @@ export default function UserDashboardPage() {
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
const res = await (apiV2Data("post", "/me/tickets/email", {
body: { ticketIds, channel: "whatsapp" },
authToken: token,
});
}) as Promise<{ message?: string }>);
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
@@ -422,7 +420,7 @@ export default function UserDashboardPage() {
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
setCodeInput("");
@@ -442,7 +440,7 @@ export default function UserDashboardPage() {
try {
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
setRegistrations(myRegs);
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
};
@@ -602,7 +600,7 @@ export default function UserDashboardPage() {
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
// Recompute billing totals using the backend's tranche-aware totalDueComputed
@@ -921,7 +919,7 @@ export default function UserDashboardPage() {
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
const r = await (apiV2Data("delete", "/tickets/{id}/transfer", { params: { id: t.id }, authToken: token }) as Promise<{ message?: string }>);
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
@@ -3,11 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Megaphone, Download, PencilLine, Trash2, ListChecks, type LucideIcon } 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";
@@ -81,7 +82,7 @@ export default function PrivacyCentrePage() {
const load = useCallback(async () => {
if (!token) return;
try {
setInfo(await apiFetch<PrivacyInfo>("/api/privacy/me", { authToken: token }));
setInfo(await (apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<PrivacyInfo>));
setLoadError(null);
} catch (e) {
setLoadError(errorMessage(e) || "Could not load your privacy settings.");
@@ -99,7 +100,7 @@ export default function PrivacyCentrePage() {
// Show the new state straight away; load() puts back the saved one if it fails.
setInfo({ ...info, news: { ...info.news, [channel]: value } });
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { [channel]: value } });
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { [channel]: value } });
await load();
setNewsMsg({ type: "ok", text: value ? "You're subscribed." : "You're unsubscribed." });
} catch (e) {
@@ -118,10 +119,10 @@ export default function PrivacyCentrePage() {
setDownloading(format);
setDownloadMsg(null);
try {
const res = await fetch(`${API_BASE}/api/privacy/me/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
const res = await fetch(`${API_BASE}/api/v2/me/privacy/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || `Download failed (${res.status})`);
throw new Error(body?.error?.message || body?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -147,7 +148,7 @@ export default function PrivacyCentrePage() {
setSendingCorrection(true);
setCorrectionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", { method: "POST", authToken: token, body: { type: "correction", details: correction } });
const res = await (apiV2Data("post", "/me/privacy/requests", { authToken: token, body: { type: "correction", details: correction } }) as Promise<{ message?: string }>);
setCorrection("");
setCorrectionMsg({ type: "ok", text: res?.message || "Request sent." });
load();
@@ -170,11 +171,10 @@ export default function PrivacyCentrePage() {
setDeleting(true);
setDeleteMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", {
method: "POST",
const res = await (apiV2Data("post", "/me/privacy/requests", {
authToken: token,
body: { type: "deletion", password: deletePassword, details: deleteReason || undefined },
});
}) as Promise<{ message?: string }>);
setDeleteMsg({ type: "ok", text: res?.message || "Your account is closed." });
setTimeout(() => logout(), 3000);
} catch (e) {
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
@@ -12,7 +11,7 @@ import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn,
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -127,10 +126,10 @@ export default function UserProfilePage() {
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
method: "POST",
const res = await (apiV2Data("post", "/me/sessions/revoke", {
authToken: token,
});
body: {},
}) as Promise<{ token?: string; message?: string }>);
// Revoking sessions invalidates the token this device was using too —
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
@@ -150,7 +149,7 @@ export default function UserProfilePage() {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
const res = await (apiV2Data("get", "/me/activity", { authToken: token }) as Promise<ActivityEvent[]>);
setActivity(Array.isArray(res) ? res : []);
} catch {
// Non-critical — just leave the list empty rather than showing an error banner.
@@ -198,11 +197,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/enable", {
authToken: token,
body: { method: tfMethod, password: tfPassword },
});
}) as Promise<TwoFactorChallenge>);
setTfChallenge(res);
setTfCode("");
setTfPassword("");
@@ -220,11 +218,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/confirm", {
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
}) as Promise<{ twoFactorMethod: TwoFactorMethod }>);
const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
@@ -244,8 +241,7 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
await apiFetch("/api/users/2fa/disable", {
method: "POST",
await apiV2Data("post", "/me/two-factor/disable", {
authToken: token,
body: { password: tfPassword },
});
@@ -273,11 +269,10 @@ export default function UserProfilePage() {
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
method: "POST",
const res = await (apiV2Data("post", "/me/close", {
authToken: token,
body: { password: closePassword, deleteData },
});
}) as Promise<{ message?: string }>);
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
@@ -2,13 +2,13 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
import { apiV2Data } from "@/lib/apiV2";
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
type Msg = { type: "ok" | "err"; text: string } | null;
@@ -27,7 +27,7 @@ export default function ServingPage() {
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
setData(await (apiV2Data("get", "/me/volunteering", { authToken: token }) as Promise<Mine>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
}
@@ -42,7 +42,7 @@ export default function ServingPage() {
const loadRoster = useCallback(async (id: string) => {
if (!token || !id) { setRoster(null); return; }
try {
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
setRoster(await (apiV2Data("get", "/events/{id}/volunteer-roles", { params: { id: id }, authToken: token }) as Promise<Roster>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
}
@@ -65,10 +65,10 @@ export default function ServingPage() {
const askCover = (s: MySlot) => {
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
if (note === null) return;
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
call(() => apiV2Data("post", "/volunteer-slots/{id}/swap", { params: { id: s.slotId }, authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
};
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
&& call(() => apiV2Data("delete", "/volunteer-slots/{id}", { params: { id: s.slotId }, authToken: token }), "You're off the roster for this shift.");
const upcoming = (data?.slots || []).filter(s => !s.past);
const past = (data?.slots || []).filter(s => s.past);
@@ -109,7 +109,7 @@ export default function ServingPage() {
{!s.checkedInAt && (
<div className="mt-3 flex flex-wrap gap-2">
{s.swapId
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
? <button disabled={busy} onClick={() => call(() => apiV2Data("delete", "/volunteer-swaps/{id}", { params: { id: s.swapId! }, authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can&apos;t make it</button>}
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
</div>
@@ -137,7 +137,7 @@ export default function ServingPage() {
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can&apos;t make it{c.note ? `: “${c.note}”` : ""}</div>
</div>
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
<button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: c.swapId }, authToken: token, body: {} }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
</div>
))}
</section>
@@ -170,10 +170,10 @@ export default function ServingPage() {
{r.mine
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You&apos;re serving</span>
: r.coverNeeded.length > 0
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
? <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: r.coverNeeded[0].swapId }, authToken: token, body: {} }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
: r.full
? <span className="text-sm text-gray-500">Full</span>
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
: <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-roles/{id}/sign-up", { params: { id: r.id }, authToken: token, body: {} }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
</div>
))}
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
+1 -1
View File
@@ -32,7 +32,7 @@ type Event = {
};
export default async function EventsPage() {
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const events = await apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
+3 -2
View File
@@ -7,7 +7,8 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
@@ -28,7 +29,7 @@ function PrivacyNotice() {
useEffect(() => {
setNotice(null);
setError(null);
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
(apiV2Data("get", "/privacy/notice", { query: { version: versionParam || undefined } }) as Promise<PublicNotice>)
.then(setNotice)
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
}, [versionParam]);
+3 -3
View File
@@ -38,9 +38,9 @@ const FEATURES = [
async function getOrgName(): Promise<string> {
if (!API_BASE) return appName;
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
const settings = await res.json();
const settings = (await res.json())?.data;
return settings?.org_name || appName;
} catch {
return appName;
@@ -49,7 +49,7 @@ async function getOrgName(): Promise<string> {
export default async function HomePage() {
const [events, displayName] = await Promise.all([
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
@@ -75,7 +75,7 @@ function RegistrationSuccessContent() {
(async () => {
try {
if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
await apiV2Data("post", "/tickets/generate", { authToken: token, body: { registrationId: reg.id } });
}
} catch {}
setTimeout(() => router.replace('/dashboard'), 600);
+2 -2
View File
@@ -2,10 +2,10 @@
import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ResetPasswordContent() {
const search = useSearchParams();
@@ -24,7 +24,7 @@ function ResetPasswordContent() {
try {
setLoading(true);
setStatus(null);
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
await apiV2Data("post", "/auth/password/reset", { body: { token, password } });
setStatus("Your password has been reset. You can now log in.");
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
+16 -15
View File
@@ -2,7 +2,7 @@
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -13,7 +13,8 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
import { toAuthResponse } from "@/contexts/AuthContext";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -337,15 +338,16 @@ export default function SelfServicePage() {
try {
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
let data: AuthResponse | TwoFactorChallenge;
if (setupChallenge) {
const { data: session } = await apiV2("post", "/auth/login/code", { body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false } });
data = toAuthResponse(session);
} else {
const { data: res } = await apiV2("post", "/auth/login", { body: { identifier: setupEmail, password: setupPassword } });
data = res.status === "code_required"
? { twoFactorRequired: true, challengeId: res.challengeId, method: res.method, destination: res.destination || "" }
: toAuthResponse(res);
}
if (isTwoFactorChallenge(data)) {
setSetupChallenge(data);
setSetupCode("");
@@ -394,8 +396,7 @@ export default function SelfServicePage() {
// holds, rather than logging in again — a fresh login would send a new
// two-factor code every time the event is changed.
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
await apiFetch("/api/users/verify-password", {
method: "POST",
await apiV2Data("post", "/me/verify-password", {
authToken: supervisorToken,
body: { password: changePassword },
});
@@ -635,10 +636,10 @@ export default function SelfServicePage() {
const params = new URLSearchParams();
if (kind === "email") params.set("email", value);
else params.set("phone", value);
const data = await apiFetch<{
const data = await (apiV2Data("get", "/users/exists", { query: Object.fromEntries(params), authToken: supervisorToken }) as Promise<{
exists: boolean;
user?: MatchedAccount | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
}>);
if (data?.exists && data.user) {
setMatch(data.user);
setLookupMessage("Account found — details filled in below.");
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
const series = await loadSeries(slug);
if (!series) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?series=${encodeURIComponent(series.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 -2
View File
@@ -6,9 +6,9 @@ import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { CheckCircle2, HandHeart } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
@@ -23,7 +23,7 @@ function CheckIn() {
useEffect(() => {
if (!code || loading || !token || sent.current) return;
sent.current = true;
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
(apiV2Data("post", "/volunteering/check-in", { authToken: token, body: { qr: code } }) as Promise<Result>)
.then(setResult)
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
}, [code, loading, token]);
+4 -6
View File
@@ -3,9 +3,9 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { Megaphone } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -38,7 +38,7 @@ export default function SetBannerPage() {
// Load current banner
useEffect(() => {
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
(apiV2Data("get", "/banner") as Promise<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>)
.then(b => {
if (!b) return;
setMessage(b.message || "");
@@ -65,8 +65,7 @@ export default function SetBannerPage() {
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
await apiV2Data("put", "/banner", {
authToken: token,
body: {
message,
@@ -89,8 +88,7 @@ export default function SetBannerPage() {
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
await apiV2Data("put", "/banner", {
authToken: token,
body: { message: "", type: "info", liveFrom: null, liveTill: null },
});
+7 -10
View File
@@ -2,13 +2,13 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { errorMessage } from "@/lib/utils";
import { ConnectForm } from "@/components/admin/LicenceTab";
import { uploadImage } from "@/lib/eventSetup";
import { apiV2Data } from "@/lib/apiV2";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -103,7 +103,7 @@ export default function SetupPage() {
// Check setup isn't already done
useEffect(() => {
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
.catch(() => {});
}, [router]);
@@ -144,10 +144,9 @@ export default function SetupPage() {
if (adminPassword !== adminConfirm) { setError("Passwords do not match."); return; }
setLoading(true);
try {
const res = await apiFetch<{ token: string }>("/api/setup/register", {
method: "POST",
const res = await (apiV2Data("post", "/setup/register", {
body: { adminName, adminEmail, adminPassword },
});
}) as Promise<{ token: string }>);
setSetupToken(res.token);
next();
} catch (e) {
@@ -166,8 +165,7 @@ export default function SetupPage() {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; message?: string }>("/api/settings/test-smtp", {
method: "POST",
const res = await (apiV2Data("post", "/settings/test-smtp", {
authToken: setupToken,
body: {
host: smtpHost.trim(),
@@ -177,7 +175,7 @@ export default function SetupPage() {
user: smtpUser.trim() || undefined,
pass: smtpPass || undefined,
},
});
}) as Promise<{ ok: boolean; message?: string }>);
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
} catch (e) {
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
@@ -235,8 +233,7 @@ export default function SetupPage() {
if (smtpPass.trim()) settings.smtp_pass = smtpPass.trim();
}
await apiFetch("/api/setup", {
method: "POST",
await apiV2Data("post", "/setup", {
authToken: setupToken,
body: { settings },
});
+1 -2
View File
@@ -1,5 +1,4 @@
import type { MetadataRoute } from "next";
import { apiFetch } from "@/lib/api";
import { appUrl } from "@/lib/siteConfig";
import { apiV2Data } from "@/lib/apiV2";
@@ -18,7 +17,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let events: Event[] = [];
try {
events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate } } });
events = await (apiV2Data("get", "/events", { nextOptions: { next: { revalidate } } }) as Promise<Event[]>);
} catch {
// Backend unreachable at build/revalidate time — ship the static entries only,
// same degrade-gracefully posture as layout.tsx's getServerSettings.
+3 -3
View File
@@ -3,10 +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 } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Offer = {
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
@@ -36,7 +36,7 @@ export default function TransferOfferPage() {
useEffect(() => {
if (!token) return;
apiFetch<Offer>(`/api/tickets/transfer-offer/${encodeURIComponent(token)}`)
(apiV2Data("get", "/ticket-transfers/{token}", { params: { token: token } }) as Promise<Offer>)
.then(setOffer)
.catch(e => setLoadError(e?.message || "This transfer link isn't valid."));
}, [token]);
@@ -45,7 +45,7 @@ export default function TransferOfferPage() {
setBusy(true);
setError(null);
try {
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
const r = await (apiV2Data("post", action === "accept" ? "/ticket-transfers/{token}/accept" : "/ticket-transfers/{token}/decline", { params: { token: token! }, body: {} }) as Promise<{ message: string }>);
setDone({ ok: action === "accept", message: r.message });
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
+4 -3
View File
@@ -4,8 +4,9 @@ import Link from "next/link";
import { useSearchParams } 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 { MailX } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type Info =
| { kind: "account"; email: string; hasPhone: boolean; news: { email: boolean; whatsapp: boolean } }
@@ -21,7 +22,7 @@ function Unsubscribe() {
useEffect(() => {
if (!token) return;
apiFetch<Info>(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`)
(apiV2Data("get", "/unsubscribe/{token}", { params: { token: token } }) as Promise<Info>)
.then(setInfo)
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this link."));
}, [token]);
@@ -29,7 +30,7 @@ function Unsubscribe() {
const unsubscribe = async (channels?: ("email" | "whatsapp")[]) => {
setBusy(true);
try {
await apiFetch(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`, { method: "POST", body: channels ? { channels } : {} });
await apiV2Data("post", "/unsubscribe/{token}", { params: { token: token }, body: channels ? { channels } : {} });
setDone(true);
} catch (e) {
setError(e instanceof ApiError ? e.message : "Couldn't unsubscribe. Please try again.");
+8 -7
View File
@@ -2,10 +2,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DatabaseBackup, Download } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
interface BackupEntry {
filename: string;
@@ -34,8 +35,8 @@ export function BackupsTab({ active }: { active: boolean }) {
setLoading(true);
try {
const [list, allSettings] = await Promise.all([
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
(apiV2Data("get", "/backups", { authToken: token }) as Promise<BackupEntry[]>),
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>),
]);
setBackups(list || []);
setRetainCount(allSettings?.backup_retain_count || "14");
@@ -50,7 +51,7 @@ export function BackupsTab({ active }: { active: boolean }) {
if (!token) return;
setSavingRetain(true);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
await apiV2Data("put", "/settings", { authToken: token, body: { backup_retain_count: retainCount } });
setMessage({ type: "ok", text: "Retention setting saved." });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
@@ -66,7 +67,7 @@ export function BackupsTab({ active }: { active: boolean }) {
setRunning(true);
setMessage(null);
try {
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/backups/run", { authToken: token, body: {} }) as Promise<{ filename: string }>);
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
await load();
} catch (e) {
@@ -141,14 +142,14 @@ export function BackupsTab({ active }: { active: boolean }) {
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
<td className="p-2.5">
<a
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
href={`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`}
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
onClick={(e) => {
// authenticated download: fetch as blob rather than a bare link,
// since this route requires an admin bearer token
e.preventDefault();
if (!token) return;
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
fetch(`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Plus, ArrowUp, ArrowDown, Archive, ArchiveRestore, Trash2, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
@@ -41,7 +40,7 @@ function RecipientsField({ value, onChange }: { value: Person[]; onChange: (v: P
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data?: (Person & { role?: string })[] }>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: { search: search.trim(), limit: "10" }, authToken: token }) as Promise<{ data?: (Person & { role?: string })[] }>);
if (!cancelled) setResults((res?.data || []).filter(u => u.role !== "user"));
} catch { if (!cancelled) setResults([]); }
}, 300);
+3 -3
View File
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Timer, Play, RefreshCw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
@@ -50,7 +50,7 @@ export function JobsTab({ active }: { active: boolean }) {
if (!token) return;
setLoading(true);
try {
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
setData(await (apiV2Data("get", "/admin/jobs", { authToken: token }) as Promise<JobsResponse>));
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
} finally {
@@ -64,7 +64,7 @@ export function JobsTab({ active }: { active: boolean }) {
if (!token) return;
setRunning(job.name);
try {
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
const r = await (apiV2Data("post", "/admin/jobs/{name}/run", { params: { name: job.name }, authToken: token, body: {} }) as Promise<{ queued: boolean }>);
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
setTimeout(load, r.queued ? 4000 : 0);
} catch (e) {
+9 -9
View File
@@ -2,9 +2,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
export type HubStatus = {
state: "active" | "grace" | "suspended" | "revoked" | "unlicensed";
@@ -76,7 +76,7 @@ export function ConnectForm({ token, onConnected, compact = false }: { token: st
setBusy(true);
setError(null);
try {
onConnected(await apiFetch<HubStatus>("/api/hub/connect", { method: "POST", authToken: token, body: { licenceKey: key } }));
onConnected(await (apiV2Data("post", "/hub/connect", { authToken: token, body: { licenceKey: key } }) as Promise<HubStatus>));
setKey("");
} catch (err) {
setError(errorMessage(err) || "Could not connect.");
@@ -107,7 +107,7 @@ export function LicenceTab({ active }: { active: boolean }) {
const load = useCallback(async () => {
if (!token) return;
try { setStatus(await apiFetch<HubStatus>("/api/hub/status", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
try { setStatus(await (apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
}, [token]);
useEffect(() => { if (active) load(); }, [active, load]);
@@ -115,7 +115,7 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/heartbeat", { method: "POST", authToken: token }));
setStatus(await (apiV2Data("post", "/hub/heartbeat", { authToken: token, body: {} }) as Promise<HubStatus>));
setMsg({ type: "ok", text: "Checked with the Hub." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
@@ -130,7 +130,7 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
setStatus(await apiV2Raw<HubStatus>("post", path, { authToken: token, body: body ?? {} }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
@@ -138,14 +138,14 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(false);
}
};
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
const requestUpdate = () => updateAction("/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
// Same request again: the Hub moves the open request on to the latest version.
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
const changeRequest = () => updateAction("/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
const withdrawRequest = () => {
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
updateAction("/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
};
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
const snooze = (on: boolean) => updateAction("/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -2,10 +2,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
interface PayFastConfig {
merchantId: string;
@@ -39,7 +39,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
const load = useCallback(async () => {
if (!token) return;
try {
const c = await apiFetch<PayFastConfig>("/api/payfast/config", { authToken: token });
const c = await (apiV2Data("get", "/payments/payfast/config", { authToken: token }) as Promise<PayFastConfig>);
setCfg(c);
setMerchantId(c.merchantId);
setMerchantKey(c.merchantKey);
@@ -57,8 +57,8 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
if (!token) return;
setSaving(true);
try {
await apiFetch("/api/payfast/config", {
method: "PUT", authToken: token,
await apiV2Data("put", "/payments/payfast/config", {
authToken: token,
// An empty passphrase box keeps the saved one.
body: { merchantId, merchantKey, mode, ...(passphrase ? { passphrase } : {}) },
});
@@ -78,7 +78,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
if (!token) return;
setTesting(true);
try {
const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/payments/payfast/test", { authToken: token, body: {} }) as Promise<{ message: string }>);
setMessage({ type: "ok", text: res.message });
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" });
@@ -90,7 +90,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
const removePassphrase = async () => {
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
try {
await apiFetch("/api/payfast/config", { method: "PUT", authToken: token, body: { passphrase: "" } });
await apiV2Data("put", "/payments/payfast/config", { authToken: token, body: { passphrase: "" } });
setMessage({ type: "ok", text: "Passphrase removed." });
await load();
} catch (err) {
@@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { CheckSquare, X, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { downloadReportExcel } from "@/lib/export";
import type { Event, Registration } from "@/types";
@@ -56,7 +56,7 @@ export function RegistrationBulkBar({ selected, events, token, onClear, onDone }
useEffect(() => {
if (action !== "allocate" || !oneEvent) { setSets([]); return; }
apiFetch<{ sets?: AllocationSet[] }>(`/api/allocations/event/${oneEvent.id}`, { authToken: token })
(apiV2Data("get", "/events/{id}/allocations", { params: { id: oneEvent.id }, authToken: token }) as Promise<{ sets?: AllocationSet[] }>)
.then(b => setSets((b.sets || []).filter(s => s.groups.length)))
.catch(() => setSets([]));
}, [action, oneEvent, token]);
+10 -10
View File
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { WebhookGuide } from "./WebhookGuide";
import { apiV2Data } from "@/lib/apiV2";
type EventType = { type: string; label: string; description: string; example?: unknown };
type Endpoint = {
@@ -60,7 +60,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
const qs = new URLSearchParams({ limit: String(PAGE) });
if (status) qs.set("status", status);
if (before) qs.set("before", before);
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
return (apiV2Data("get", "/outbound-webhooks/{id}/deliveries", { params: { id: endpointId }, query: Object.fromEntries(qs), authToken: token }) as Promise<Delivery[]>).catch(() => [] as Delivery[]);
}, [endpointId, token, status]);
const load = useCallback(async () => {
const page = await fetchPage();
@@ -76,7 +76,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
};
const resend = async (id: string) => {
setBusy(id);
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
await apiV2Data("post", "/outbound-webhook-deliveries/{id}/resend", { params: { id: id }, authToken: token, body: {} }).catch(() => {});
setBusy(null);
load();
};
@@ -139,7 +139,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
try { setData(await (apiV2Data("get", "/outbound-webhooks", { authToken: token }) as Promise<ListResponse>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
}, [token]);
useEffect(() => { if (active) load(); }, [active, load]);
@@ -152,10 +152,10 @@ export function WebhooksTab({ active }: { active: boolean }) {
if (!draft) return;
const body = { name: draft.name, url: draft.url, events: draft.events };
if (draft.id) {
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: draft.id }, authToken: token, body });
setMsg({ type: "ok", text: "Webhook saved." });
} else {
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
const made = await (apiV2Data("post", "/outbound-webhooks", { authToken: token, body }) as Promise<Endpoint & { secret: string }>);
setSecret({ endpointId: made.id, value: made.secret });
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
}
@@ -164,11 +164,11 @@ export function WebhooksTab({ active }: { active: boolean }) {
});
const toggle = (e: Endpoint) => run(async () => {
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token, body: { isActive: !e.isActive } });
await load();
});
const test = (e: Endpoint) => run(async () => {
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/test", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ status: string; statusCode: number | null; error: string | null }>);
setMsg(r.status === "succeeded"
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
@@ -177,12 +177,12 @@ export function WebhooksTab({ active }: { active: boolean }) {
});
const rotate = (e: Endpoint) => run(async () => {
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/rotate-secret", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ secret: string }>);
setSecret({ endpointId: e.id, value: r.secret });
});
const remove = (e: Endpoint) => run(async () => {
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token });
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
await load();
});
+13 -11
View File
@@ -2,10 +2,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Source = "db" | "env" | "none";
@@ -98,7 +98,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
const loadHooks = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
const res = await (apiV2Data("get", "/payments/yoco/webhooks", { authToken: token }) as Promise<{ webhooks: YocoWebhook[] }>);
setHooks(res.webhooks || []);
setHooksError(null);
} catch (e) {
@@ -111,7 +111,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
if (!token) return;
setLoading(true);
try {
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
const c = await (apiV2Data("get", "/payments/yoco/config", { authToken: token }) as Promise<YocoConfig>);
setCfg(c);
setHookUrl((prev) => prev || c.webhookUrl);
if (c.secretKeySource !== "none") await loadHooks();
@@ -141,29 +141,31 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
};
const saveKey = (value: string) => run("key", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
await apiV2Data("put", "/payments/yoco/config", { authToken: token, body: { secretKey: value } });
setEditingKey(false);
setKeyInput("");
return value ? "Secret key saved." : "Secret key removed.";
});
const saveSecret = (value: string) => run("secret", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { webhookSecret: value } });
await apiV2Data("put", "/payments/yoco/config", { authToken: token, body: { webhookSecret: value } });
setSecretInput("");
return value ? "Webhook secret saved." : "Webhook secret removed.";
});
const generate = () => run("generate", async () => {
const res = await apiFetch<{ message: string }>("/api/yoco/webhooks", {
method: "POST", authToken: token!, body: { name: hookName.trim(), url: hookUrl.trim() },
});
const res = await (apiV2Data("post", "/payments/yoco/webhooks", {
authToken: token,
body: { name: hookName.trim(), url: hookUrl.trim() },
}) as Promise<{ message: string }>);
return res.message;
});
const removeHook = (id: string) => run(`delete:${id}`, async () => {
const res = await apiFetch<{ message: string }>(`/api/yoco/webhooks/${encodeURIComponent(id)}`, {
method: "DELETE", authToken: token!,
});
const res = await (apiV2Data("delete", "/payments/yoco/webhooks/{id}", {
params: { id: id },
authToken: token,
}) as Promise<{ message: string }>);
setConfirmDelete(null);
return res.message;
});
@@ -2,9 +2,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Copy, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { apiV2Data } from "@/lib/apiV2";
type Status = "open" | "contained" | "closed";
type Breach = {
@@ -90,7 +90,7 @@ export function BreachesPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
setList(await (apiV2Data("get", "/privacy/breaches", { authToken: token }) as Promise<Breach[]>));
} catch (e) {
setError(errorMessage(e) || "Could not load the register.");
}
@@ -101,8 +101,8 @@ export function BreachesPanel({ token }: { token: string }) {
setBusy(true);
setError(null);
try {
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
if (id === "new") await apiV2Data("post", "/privacy/breaches", { authToken: token, body });
else await apiV2Data("put", "/privacy/breaches/{id}", { params: { id: id }, authToken: token, body });
setEditing(null);
await load();
} catch (e) {
@@ -1,8 +1,8 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
const selectCls = "border rounded-lg px-3 py-2 text-sm bg-white";
@@ -44,7 +44,7 @@ export function ConsentLogPanel({ token }: { token: string }) {
const load = useCallback(async () => {
setData(null);
try {
setData(await apiFetch(`/api/privacy/consent-log?type=${type}&page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`, { authToken: token }));
setData(await (apiV2Data("get", "/privacy/consent-log", { query: { type, page: String(page), q: query || undefined }, authToken: token }) as Promise<NonNullable<typeof data>>));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the consent log.");
@@ -137,7 +137,7 @@ export function AccessLogPanel({ token }: { token: string }) {
if (action) params.set("action", action);
if (actorId) params.set("actorId", actorId);
if (sensitiveOnly) params.set("sensitive", "true");
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
setData(await (apiV2Data("get", "/privacy/access-log", { query: Object.fromEntries(new URLSearchParams(params)), authToken: token }) as Promise<AccessResponse>));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the access log.");
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine, Sparkles } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
import { apiV2Data } from "@/lib/apiV2";
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
@@ -41,7 +41,7 @@ export function NoticeEditor({ token }: { token: string }) {
const load = useCallback(async () => {
try {
const d = await apiFetch<Draft>("/api/privacy/notice/draft", { authToken: token });
const d = await (apiV2Data("get", "/privacy/notice/draft", { authToken: token }) as Promise<Draft>);
setDraft(d);
setSections(d.sections);
setDirty(false);
@@ -64,7 +64,7 @@ export function NoticeEditor({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>("/api/privacy/notice/draft", { method: "PUT", authToken: token, body: { sections } });
const res = await (apiV2Data("put", "/privacy/notice/draft", { authToken: token, body: { sections } }) as Promise<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>);
setDraft(d => (d ? { ...d, sections: res.sections, rendered: res.rendered, isDraft: true, savedAt: res.savedAt } : d));
setSections(res.sections);
setDirty(false);
@@ -80,7 +80,7 @@ export function NoticeEditor({ token }: { token: string }) {
if (!confirm("Throw away the draft and go back to the published text?")) return;
setBusy(true);
try {
await apiFetch("/api/privacy/notice/draft", { method: "DELETE", authToken: token });
await apiV2Data("delete", "/privacy/notice/draft", { authToken: token });
await load();
setMsg({ type: "ok", text: "Draft discarded." });
} catch (e) {
@@ -95,7 +95,7 @@ export function NoticeEditor({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ version: number }>("/api/privacy/notice/publish", { method: "POST", authToken: token, body: { sections, changeSummary, requiresAcceptance } });
const res = await (apiV2Data("post", "/privacy/notice/publish", { authToken: token, body: { sections, changeSummary, requiresAcceptance } }) as Promise<{ version: number }>);
setPublishing(false);
setChangeSummary("");
setRequiresAcceptance(false);
@@ -112,7 +112,7 @@ export function NoticeEditor({ token }: { token: string }) {
const markReviewed = async () => {
setBusy(true);
try {
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
await apiV2Data("post", "/privacy/notice/builtin-reviewed", { authToken: token, body: {} });
setDraft(d => (d ? { ...d, builtInUpdates: [] } : d));
setMsg({ type: "ok", text: "Done. Remember to save the draft and publish if you took any new text." });
} catch (e) {
@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Operator = { id: string; name: string; purpose: string; dataShared: string; location: string | null; website: string | null; active: boolean; order: number };
type Form = { name: string; purpose: string; dataShared: string; location: string; website: string; active: boolean };
@@ -41,7 +41,7 @@ export function OperatorsPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setOps(await apiFetch<Operator[]>("/api/privacy/operators", { authToken: token }));
setOps(await (apiV2Data("get", "/privacy/operators", { authToken: token }) as Promise<Operator[]>));
} catch (e) {
setError(errorMessage(e) || "Could not load operators.");
}
@@ -53,8 +53,8 @@ export function OperatorsPanel({ token }: { token: string }) {
setError(null);
try {
const body = { ...f, location: f.location || null, website: f.website || null };
if (editing === "new") await apiFetch("/api/privacy/operators", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/operators/${editing}`, { method: "PUT", authToken: token, body });
if (editing === "new") await apiV2Data("post", "/privacy/operators", { authToken: token, body });
else await apiV2Data("put", "/privacy/operators/{id}", { params: { id: editing! }, authToken: token, body });
setEditing(null);
await load();
} catch (e) {
@@ -67,7 +67,7 @@ export function OperatorsPanel({ token }: { token: string }) {
const remove = async (op: Operator) => {
if (!confirm(`Remove ${op.name} from the register? (Untick "In use" instead if you might use them again.)`)) return;
try {
await apiFetch(`/api/privacy/operators/${op.id}`, { method: "DELETE", authToken: token });
await apiV2Data("delete", "/privacy/operators/{id}", { params: { id: op.id }, authToken: token });
await load();
} catch (e) {
setError(errorMessage(e) || "Could not remove.");
@@ -3,8 +3,8 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
@@ -16,7 +16,7 @@ export function ReadinessPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
setData(await (apiV2Data("get", "/privacy/readiness", { authToken: token }) as Promise<Readiness>));
} catch (e) {
setError(errorMessage(e) || "Could not load the checklist.");
}
@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Policy = { enabled: boolean; months: number };
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
@@ -39,7 +39,7 @@ export function RetentionPanel({ token }: { token: string }) {
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
const load = useCallback(async () => {
try {
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
apply(await (apiV2Data("get", "/privacy/retention", { authToken: token }) as Promise<State>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
}
@@ -52,7 +52,7 @@ export function RetentionPanel({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
apply(await (apiV2Data("put", "/privacy/retention", { authToken: token, body: { policies } }) as Promise<State>));
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
@@ -66,7 +66,7 @@ export function RetentionPanel({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/privacy/retention/run", { authToken: token, body: {} }) as Promise<{ state: State }>);
apply(res.state);
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
} catch (e) {
@@ -4,10 +4,10 @@ import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CalendarClock, ArrowRight, Baby, HandHeart, UserCheck, Banknote, Inbox, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { Event } from "@/types";
import { apiV2Data } from "@/lib/apiV2";
type TodayEvent = {
id: string; title: string; location: string | null; campus: Event["campus"]; isHidden: boolean; kidsCheckIn: boolean;
@@ -35,7 +35,7 @@ export function TodayPanel({ supervisor }: { supervisor: boolean }) {
const [data, setData] = useState<Today | null>(null);
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<Today>("/api/dashboard/today", { authToken: token })); } catch { /* keep what's shown */ }
try { setData(await (apiV2Data("get", "/dashboard/today", { authToken: token }) as Promise<Today>)); } catch { /* keep what's shown */ }
}, [token]);
useEffect(() => { load(); }, [load]);
useVisiblePolling(load, 30000, !!token);
@@ -2,11 +2,12 @@
import React, { useEffect, useState } from "react";
import { Award, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { fetchApiFile, saveBlob } from "@/lib/export";
import { formatDate } from "@/lib/date";
import { errorMessage } from "@/lib/utils";
import type { MyCertificate } from "@/types";
import { apiV2Data } from "@/lib/apiV2";
// Completion certificates on My Events. Hidden until the person has one.
export function MyCertificates({ token }: { token: string | null }) {
@@ -15,7 +16,7 @@ export function MyCertificates({ token }: { token: string | null }) {
useEffect(() => {
if (!token) return;
apiFetch<MyCertificate[]>("/api/attendance/certificates/mine", { authToken: token })
(apiV2Data("get", "/me/certificates", { authToken: token }) as Promise<MyCertificate[]>)
.then(list => setCerts(Array.isArray(list) ? list : []))
.catch(() => setCerts([]));
}, [token]);
@@ -26,7 +27,7 @@ export function MyCertificates({ token }: { token: string | null }) {
if (!token) return;
setError(null);
try {
saveBlob(await fetchApiFile(API_BASE, token, `/api/attendance/certificates/${c.id}/pdf`), `Certificate - ${c.event.title.replace(/[^a-z0-9 -]/gi, "")}.pdf`);
saveBlob(await fetchApiFile(API_BASE, token, `/api/v2/certificates/${c.id}/pdf`), `Certificate - ${c.event.title.replace(/[^a-z0-9 -]/gi, "")}.pdf`);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
@@ -1,9 +1,9 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import type { KidsEvent } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
type Props = {
token: string | null;
@@ -21,7 +21,7 @@ export function KidsEventPicker({ token, eventId, sessionId, onChange }: Props)
useEffect(() => {
if (!token) return;
apiFetch<KidsEvent[]>("/api/kids/events", { authToken: token })
(apiV2Data("get", "/kids/events", { authToken: token }) as Promise<KidsEvent[]>)
.then(list => {
setEvents(list);
if (eventId || !list.length) return;
+5 -5
View File
@@ -1,10 +1,10 @@
"use client";
import React, { useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { AlertTriangle, BellRing, HeartPulse, LogOut, ShieldAlert, Users } from "lucide-react";
import { ageRange, type KidsBoard, type KidsCheckIn, type KidsRoom } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
type Props = {
board: KidsBoard;
@@ -52,17 +52,17 @@ export function RoomBoard({ board, token, roomId, onChanged, onMessage }: Props)
const page = (c: KidsCheckIn) => {
const extra = prompt(`Message the parents of ${c.firstName}? They'll be asked to come to the room. Add a note (optional):`, "");
if (extra === null) return;
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/page`, { method: "POST", authToken: token, body: { message: extra } }), `Message sent to ${c.firstName}'s parents.`);
act(c.id, () => apiV2Data("post", "/kids/check-ins/{id}/page", { params: { id: c.id }, authToken: token, body: { message: extra } }), `Message sent to ${c.firstName}'s parents.`);
};
const override = (c: KidsCheckIn) => {
const who = prompt(`Who is collecting ${c.firstName}?`, c.family?.parents[0]?.name || "");
if (!who) return;
const reason = prompt("Why without the pickup code? (recorded in the audit log)", "");
if (!reason) return;
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/override`, { method: "POST", authToken: token, body: { collectedBy: who, reason } }), `${c.firstName} checked out.`);
act(c.id, () => apiV2Data("post", "/kids/check-ins/{id}/override", { params: { id: c.id }, authToken: token, body: { collectedBy: who, reason } }), `${c.firstName} checked out.`);
};
const move = (c: KidsCheckIn, to: string) => act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/room`, { method: "PUT", authToken: token, body: { roomId: to } }), `${c.firstName} moved.`);
const resolve = (id: string) => act(id, () => apiFetch(`/api/kids/alerts/${id}/resolve`, { method: "POST", authToken: token }), "Alert cleared.");
const move = (c: KidsCheckIn, to: string) => act(c.id, () => apiV2Data("put", "/kids/check-ins/{id}/room", { params: { id: c.id }, authToken: token, body: { roomId: to } }), `${c.firstName} moved.`);
const resolve = (id: string) => act(id, () => apiV2Data("post", "/kids/alerts/{id}/resolve", { params: { id: id }, authToken: token, body: {} }), "Alert cleared.");
return (
<div className="space-y-4">
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { API_BASE, fetchManagedEvents } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
@@ -346,7 +346,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<Ticket[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
const tickets = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: id }, authToken: token }) as Promise<Ticket[]>);
result[id] = { used: tickets.filter(t => t.isUsed).length, unused: tickets.filter(t => !t.isUsed).length };
} catch {
result[id] = { used: 0, unused: 0 };
@@ -374,7 +374,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<FunnelReport>(`/api/analytics/funnel?${query.toString()}`, { authToken: token });
return await (apiV2Data("get", "/analytics/funnel", { query: Object.fromEntries(query), authToken: token }) as Promise<FunnelReport>);
} catch {
return null;
}
@@ -2,9 +2,9 @@
import React, { useEffect, useState } from "react";
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { OtherLock } from "./useOfflineScanning";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
@@ -28,7 +28,7 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
useEffect(() => {
if (!token || !eventId || eventId === "all" || !online) { setLocks([]); return; }
apiFetch<OtherLock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token })
(apiV2Data("get", "/scan-locks", { query: { eventId: eventId }, authToken: token }) as Promise<OtherLock[]>)
.then(setLocks).catch(() => setLocks([]));
}, [token, eventId, online, lock]);
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import {
OfflineLock, SnapshotTicket, clearOffline, deviceId, deviceName, findTicket, getOfflineLock,
queuedScans, recordScan, removeQueued, startOffline,
@@ -57,9 +57,11 @@ export function useOfflineScanning(token: string | null) {
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
let accepted = 0, conflicts = 0;
for (const [lockId, scans] of byLock) {
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: unknown[] }>(`/api/scan-locks/${lockId}/sync`, {
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
});
const res = await (apiV2Data("post", "/scan-locks/{id}/sync", {
params: { id: lockId },
authToken: token,
body: { deviceId: deviceId(), scans },
}) as Promise<{ accepted: number; duplicates: number; conflicts: unknown[] }>);
accepted += res.accepted;
conflicts += res.conflicts.length;
// Synced (or already on the server) — either way they're done on this device.
@@ -85,13 +87,14 @@ export function useOfflineScanning(token: string | null) {
const enable = useCallback(async (eventId: string, eventTitle: string, sectionId: string | null, sectionName: string | null) => {
if (!token) throw new Error("Log in first.");
const created = await apiFetch<{ id: string }>("/api/scan-locks", {
method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
});
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[]; session?: { id: string; label: string } | null; noSessionToday?: boolean }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
const created = await (apiV2Data("post", "/scan-locks", {
authToken: token,
body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
}) as Promise<{ id: string }>);
const snap = await (apiV2Data("get", "/scan-locks/{id}/snapshot", { params: { id: created.id }, query: { deviceId: deviceId() }, authToken: token }) as Promise<{ snapshotAt: string; tickets: SnapshotTicket[]; session?: { id: string; label: string } | null; noSessionToday?: boolean }>);
// Multi-day events: the downloaded list is for today's session, so there must be one.
if (snap.noSessionToday) {
await apiFetch(`/api/scan-locks/${created.id}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } }).catch(() => {});
await apiV2Data("delete", "/scan-locks/{id}", { params: { id: created.id }, authToken: token, body: { deviceId: deviceId() } }).catch(() => {});
throw new Error("There's no session of this event open for check-in today, so offline mode can't start.");
}
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt, sessionLabel: snap.session?.label || null };
@@ -104,7 +107,7 @@ export function useOfflineScanning(token: string | null) {
if (!lock || !token) return;
await sync();
if ((await queuedScans()).length > 0) throw new Error("Some scans haven't synced yet. Try again when you're online.");
await apiFetch(`/api/scan-locks/${lock.lockId}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } });
await apiV2Data("delete", "/scan-locks/{id}", { params: { id: lock.lockId }, authToken: token, body: { deviceId: deviceId() } });
await clearOffline();
setLock(null);
}, [lock, token, sync]);
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
type SetWithGroups = { id: string; name: string; groups: { id: string; name: string }[] };
@@ -20,7 +20,7 @@ export function AllocationGroupFilter({ token, eventId, value, onChange }: {
useEffect(() => {
onChange("");
if (!token || !eventId) { setSets([]); return; }
apiFetch<{ sets: SetWithGroups[] }>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token })
(apiV2Data("get", "/events/{id}/allocations", { params: { id: eventId }, authToken: token }) as Promise<{ sets: SetWithGroups[] }>)
.then(b => setSets((b.sets || []).filter(s => s.groups.length > 0)))
.catch(() => setSets([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -28,7 +28,7 @@ export function BannerBar() {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
apiFetch<Banner>("/api/banner")
(apiV2Data("get", "/banner") as Promise<Banner>)
.then(b => {
if (!b?.message) return;
setBanner(b);
@@ -4,8 +4,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation";
import { Search, Calendar, User, Banknote, ClipboardList, CornerDownLeft, Zap, FileText, Settings, BarChart2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { matchPages, type CommandPage } from "@/lib/commandPages";
import { apiV2Data } from "@/lib/apiV2";
type Result = { type: "event" | "payment" | "person" | "registration"; id: string; title: string; subtitle: string; href: string };
type Item = { key: string; group: string; title: string; subtitle?: string; href: string; icon: React.ElementType };
@@ -59,7 +59,7 @@ export function CommandPalette() {
const q = query.trim();
if (q.length < 2) { setResults([]); return; }
const timer = setTimeout(() => {
apiFetch<{ results: Result[] }>(`/api/dashboard/search?q=${encodeURIComponent(q)}`, { authToken: token })
(apiV2Data("get", "/dashboard/search", { query: { q: q }, authToken: token }) as Promise<{ results: Result[] }>)
.then(r => setResults(r.results || []))
.catch(() => setResults([]));
}, 200);
@@ -3,9 +3,9 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
import { apiV2Data } from "@/lib/apiV2";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
@@ -20,7 +20,7 @@ export function LicenceBanner() {
useEffect(() => {
if (!isAdmin || !token) return;
apiFetch<HubStatus>("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {});
(apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>).then(setStatus).catch(() => {});
}, [isAdmin, token]);
if (!isAdmin || !status) return null;
@@ -69,7 +69,7 @@ export function LicenceBanner() {
if (status.updateAvailable && !awaiting && !status.updateSnoozed) {
const notNow = async () => {
setBusy(true);
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
try { setStatus(await (apiV2Data("post", "/hub/update-snooze", { authToken: token, body: { snooze: true } }) as Promise<HubStatus>)); } catch { /* the Licence page shows why */ }
setBusy(false);
};
return (
@@ -4,8 +4,8 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type News = { email: boolean; whatsapp: boolean };
@@ -26,7 +26,7 @@ export function NewsPrompt() {
useEffect(() => {
if (!pending || !token || news) return;
apiFetch<{ news: News; canWhatsApp: boolean }>("/api/privacy/me", { authToken: token })
(apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<{ news: News; canWhatsApp: boolean }>)
.then(res => { setNews(res.news); setCanWhatsApp(res.canWhatsApp); })
.catch(() => setDone(true)); // never block the dashboard over this
}, [pending, token, news]);
@@ -38,7 +38,7 @@ export function NewsPrompt() {
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { ...choice, source: "prompt" } });
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { ...choice, source: "prompt" } });
setDone(true);
} catch (e) {
setError(errorMessage(e) || "Could not save your choice.");
@@ -4,9 +4,9 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ScrollText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
import { apiV2Data } from "@/lib/apiV2";
/**
* POPIA: asked once after login when this person never accepted the privacy notice (an account
@@ -21,7 +21,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<PublicNotice>("/api/privacy/notice").then(setNotice).catch(() => onDone());
(apiV2Data("get", "/privacy/notice") as Promise<PublicNotice>).then(setNotice).catch(() => onDone());
}, [onDone]);
if (!notice) return null;
@@ -31,7 +31,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/notice/accept", { method: "POST", authToken: token });
await apiV2Data("post", "/me/privacy/notice/accept", { authToken: token, body: {} });
onDone();
} catch (e) {
setError(errorMessage(e) || "Could not save. Please try again.");
@@ -2,9 +2,9 @@
import React, { useEffect, useState } from "react";
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubNotice } from "@/components/admin/LicenceTab";
import { apiV2Data } from "@/lib/apiV2";
const DISMISSED_KEY = "cross_events_dismissed_notices";
@@ -35,7 +35,7 @@ export function ProviderNotices() {
useEffect(() => {
if (!canSee || !token) return;
setDismissed(readDismissed());
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
(apiV2Data("get", "/hub/notices", { authToken: token }) as Promise<HubNotice[]>).then(setNotices).catch(() => {});
}, [canSee, token]);
if (!canSee) return null;
@@ -3,8 +3,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Star, Trash2, Check } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
export type FilterValues = Record<string, string | number | boolean | string[]>;
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
@@ -35,7 +35,7 @@ export function SavedFilters({ page, value, onApply, skipDefault = false, classN
const load = useCallback(async () => {
if (!token) return;
try {
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
const rows = await (apiV2Data("get", "/saved-filters", { query: { page: page }, authToken: token }) as Promise<Saved[]>);
setList(rows);
// The default, once, when the page opens.
if (!defaultDone.current) {
@@ -64,14 +64,14 @@ export function SavedFilters({ page, value, onApply, skipDefault = false, classN
const save = () => act(async () => {
setSaving(true);
try {
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
const row = await (apiV2Data("post", "/saved-filters", { authToken: token, body: { page, name, params: value, isDefault: asDefault } }) as Promise<Saved>);
setApplied(row.id); setName(""); setAsDefault(false);
} finally { setSaving(false); }
});
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
const toggleDefault = (f: Saved) => act(() => apiV2Data("put", "/saved-filters/{id}", { params: { id: f.id }, authToken: token, body: { isDefault: !f.isDefault } }));
const remove = (f: Saved) => act(async () => {
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/saved-filters/{id}", { params: { id: f.id }, authToken: token });
});
const current = list.find(f => f.id === applied);
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
// Checks once per browser session whether first-time setup is needed.
// If the backend says yes and we're not already on /setup, redirect there.
@@ -16,7 +16,7 @@ export function SetupGuard() {
if (pathname?.startsWith("/setup")) return;
checked.current = true;
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
.then((data) => {
if (data?.needsSetup) {
router.replace("/setup");
@@ -2,10 +2,10 @@
import React, { useState } from "react";
import { ArrowRightLeft } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { apiV2Data } from "@/lib/apiV2";
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
type TransferEvent = {
@@ -50,7 +50,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
try {
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
const res = await (apiV2Data("post", "/tickets/{id}/transfer", { params: { id: ticket.id }, authToken: token, body }) as Promise<{ message: string }>);
onDone(res.message);
} catch (err) {
setError(errorMessage(err) || "Couldn't transfer the ticket");

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