Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fff7c835 | ||
|
|
e310743b92 | ||
|
|
b6c13de5dc | ||
|
|
2a5b12db20 | ||
|
|
8747431bd5 | ||
|
|
e95d52ccdd | ||
|
|
fdc3db8d23 | ||
|
|
1a25896999 | ||
|
|
97c368c898 | ||
|
|
94e4de8794 | ||
|
|
3a5b042525 | ||
|
|
a7231aad0c | ||
|
|
254a5daebd | ||
|
|
28e31ffb99 | ||
|
|
6ee1438e23 | ||
|
|
a45417948d | ||
|
|
31ee2a9aee | ||
|
|
da67f80d6a | ||
|
|
4b7fb19f37 |
+123
@@ -7,6 +7,129 @@ 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
|
||||
|
||||
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
|
||||
assigned to registrations, stats, providers and their settings, open checkouts), payment links
|
||||
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
|
||||
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
|
||||
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
|
||||
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
|
||||
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
|
||||
feedback (set-up, send now, the summary, the form by link).
|
||||
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
|
||||
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
|
||||
v1's `code` as `reason`; piped files become downloads.
|
||||
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
|
||||
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
|
||||
and donate pages, the user dashboard) uses v2.
|
||||
|
||||
## [1.51.10] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
|
||||
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
|
||||
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
|
||||
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
|
||||
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
|
||||
attendees and their form links, attendee form drafts and responses
|
||||
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
|
||||
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
|
||||
`registrationController.js` (now about 60 lines of v1 wrappers).
|
||||
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
|
||||
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
|
||||
door, manual registration, self-service, payments, forms, the attendee composers, reports and
|
||||
the user dashboard all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
|
||||
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
|
||||
and links are now 1.51.16.
|
||||
|
||||
## [1.51.9] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,41 @@ 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
|
||||
feedback now run on the new version of the server's connection to the app. They work just as
|
||||
before.
|
||||
|
||||
## 1.51.10
|
||||
|
||||
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
|
||||
bulk actions — now run on the new version of the server's connection to the app. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.9
|
||||
|
||||
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.9",
|
||||
"version": "1.51.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.9",
|
||||
"version": "1.51.15",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.9",
|
||||
"version": "1.51.15",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -46,6 +46,263 @@ 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' },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
|
||||
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
|
||||
// 1.51.10: managing registrations
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
|
||||
// 1.51.9: series, season passes, bundles, coupons, campuses
|
||||
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
@@ -87,7 +344,6 @@ const SUCCESSORS: Successor[] = [
|
||||
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
|
||||
|
||||
@@ -13,6 +13,12 @@ import './resources/seriesDeals';
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
import './resources/registrationsManage';
|
||||
import './resources/money';
|
||||
import './resources/attendance';
|
||||
import './resources/kidsVolunteers';
|
||||
import './resources/peopleMessaging';
|
||||
import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
|
||||
@@ -85,7 +91,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
sessionStorage.setItem('v2docs_token', t);
|
||||
if (params.has('token')) history.replaceState(null, '', location.pathname);
|
||||
document.getElementById('login').hidden = true;
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
|
||||
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
|
||||
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { listData, v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
unscopedOnly: any;
|
||||
};
|
||||
const pay = require('../../controllers/paymentController');
|
||||
const providers = require('../../controllers/paymentProvidersController');
|
||||
const cashup = require('../../controllers/cashupController');
|
||||
const ptx = require('../../controllers/providerTransactionsController');
|
||||
const waitlist = require('../../controllers/waitlistController');
|
||||
const sponsor = require('../../controllers/sponsorshipController');
|
||||
const consent = require('../../controllers/consentController');
|
||||
const forms = require('../../controllers/formController');
|
||||
const feedback = require('../../controllers/feedbackController');
|
||||
|
||||
/** Optional text filters, passed to v1 as they come. */
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
// Links in messages are credentials, so guessing stays slow (as on v1).
|
||||
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
|
||||
|
||||
// ─── Payments ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
|
||||
access: 'public', v1: providers.listPaymentProviders,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
|
||||
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
|
||||
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
|
||||
description: 'Records any that were paid (for when a webhook was missed).',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
|
||||
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
|
||||
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
|
||||
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
|
||||
v1: pay.createPayment,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
|
||||
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
|
||||
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
|
||||
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
|
||||
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
|
||||
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
|
||||
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
|
||||
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
|
||||
description: 'By email and/or WhatsApp, for what\'s still owing.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
|
||||
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
|
||||
access: 'public', params: idParams, v1: providers.getPayPage,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
|
||||
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
|
||||
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
|
||||
});
|
||||
|
||||
// ─── Cash-ups ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cashupBase = '/events/:id/cashup';
|
||||
v1Route({
|
||||
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
|
||||
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
|
||||
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
|
||||
});
|
||||
|
||||
// ─── Provider transactions ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
|
||||
});
|
||||
|
||||
// ─── Waitlist ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
|
||||
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
|
||||
v1Route({
|
||||
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
|
||||
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
|
||||
});
|
||||
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
|
||||
v1Route({
|
||||
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
|
||||
});
|
||||
}
|
||||
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
|
||||
|
||||
// ─── Sponsorship ─────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
|
||||
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
|
||||
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
|
||||
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
|
||||
});
|
||||
|
||||
// ─── Consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
|
||||
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
|
||||
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({}), v1: consent.resendRequest,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
|
||||
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
|
||||
});
|
||||
|
||||
// ─── Attendee form answers ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
|
||||
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
|
||||
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
|
||||
});
|
||||
|
||||
// ─── Feedback ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
|
||||
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
|
||||
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { pageOf, single } from '../paging';
|
||||
import { id, idParams } from '../schemas';
|
||||
import * as admin from '../../services/registrationAdmin';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- registrations are passed through with their includes */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
};
|
||||
const { notOnSeasonPass } = require('../../utils/seasonPass') as { notOnSeasonPass: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** The v2 request as the registration service's Call. */
|
||||
function callFrom(req: Request, user: ApiUser | null, parts: { params?: object; body?: object; query?: object }): admin.Call {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return {
|
||||
actor: user ? { id: user.id, role: user.role, email: user.email } : null,
|
||||
params: (parts.params ?? {}) as Record<string, string>,
|
||||
body: parts.body ?? {},
|
||||
query: parts.query ?? {},
|
||||
readOnly: !!(req as any).readOnly,
|
||||
ip: getClientIp(req),
|
||||
listWhere: () => campusScope.listWhere(req, 'event'),
|
||||
};
|
||||
}
|
||||
|
||||
const OWNER_OR_STAFF = 'scopeEvent(registration, owner-ok)';
|
||||
const ownerOrStaff = campusScope.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration with everything the dashboards show: items and their prices, payments, attendees, coupons, `totalDueComputed`. */
|
||||
const fullRegistration = z.looseObject({ id, status: z.enum(STATUSES), eventId: id, userId: id, totalDueComputed: z.number().optional() });
|
||||
const message = single(z.looseObject({ message: z.string() }));
|
||||
const lineBody = z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() });
|
||||
const attendeeBody = z.looseObject({ id: id.optional().describe('An attendee already on it (left out = removed)'), eventOptionId: id, variantId: id.nullish(), name: z.string().max(200) });
|
||||
const responsesBody = z.object({
|
||||
responses: z.array(z.object({
|
||||
attendeeId: id.nullish().describe('The named attendee this form is for'),
|
||||
answers: z.record(z.string(), z.unknown()).describe('Field id → answer (stored as text)'),
|
||||
})).max(500),
|
||||
});
|
||||
|
||||
// ─── Owner (or staff) ────────────────────────────────────────────────────────
|
||||
|
||||
export const fullOne = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/full', tag: 'Registrations', summary: 'A registration with everything on it',
|
||||
description: [
|
||||
'Items and prices, payments, coupons and bundles, named attendees, form answers and the total due.',
|
||||
'The owner, a named attendee or staff — or anyone with the link when not signed in (registration ids can\'t be guessed). Attendees\' contact details are only shown to the payer and staff.',
|
||||
].join('\n\n'),
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, response: single(fullRegistration),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getRegistrationById(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const myFull = defineRoute({
|
||||
method: 'get', path: '/me/registrations/full', tag: 'Me', summary: 'My registrations with everything on them',
|
||||
description: 'What the dashboard shows: as `/registrations/{id}/full`, newest first, with the event and its form. Only upcoming ones unless `past=true`.',
|
||||
access: 'user',
|
||||
query: z.object({ past: z.enum(['true', 'false']).default('false') }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ query, user, req }) { return { data: plain(await admin.getUserRegistrations(callFrom(req, user, { query: { showPast: query.past } }))) }; },
|
||||
});
|
||||
|
||||
export const changeItems = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/items', tag: 'Registrations', summary: 'Change the tickets on a registration',
|
||||
description: [
|
||||
'The whole list of items it should have. Added places need stock and are priced now; places already paid for keep their price, and nothing goes below tickets already issued or the amount paid.',
|
||||
'Not for registrations made with a season pass, nor once the event has ended or is closed. Staff can add kids and volunteer places; owners can only keep or lower them.',
|
||||
].join('\n\n'),
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ options: z.array(lineBody.omit({ sessionIds: true })).min(1).max(100) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationOptions(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendInvoice = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/invoice', tag: 'Registrations', summary: 'Send me the invoice again',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']).default('email') }), response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.sendInvoice(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
export const applyCoupon = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Apply a coupon code',
|
||||
description: 'By the owner, or by staff on their behalf (logged). Not on season pass registrations.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ code: z.string().trim().min(1).max(40) }), response: single(fullRegistration.extend({ message: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.applyCoupon(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const removeCoupon = defineRoute({
|
||||
method: 'delete', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Take a coupon code off',
|
||||
description: 'Staff. Its use is freed. Not once a ticket on it has been scanned; unused tickets are withdrawn if it\'s no longer paid.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.removeCoupon(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const setAttendees = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/attendees', tag: 'Registrations', summary: 'Name the people on a registration',
|
||||
description: 'The whole list: attendees left out are removed (not once their ticket has been scanned). `ticketDelivery` says who gets the tickets.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ attendees: z.array(attendeeBody).max(1000), ticketDelivery: z.enum(['payer', 'each']).optional() }),
|
||||
response: single(z.looseObject({ id, ticketDelivery: z.string().nullable(), attendees: z.array(z.looseObject({ id, name: z.string() })) })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateAttendees(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendFormLink = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/attendees/:attendeeId/form-link', tag: 'Registrations', summary: 'Send an attendee their form',
|
||||
description: 'A link to fill in their own attendee form, by email and/or WhatsApp.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: z.object({ id, attendeeId: id }), response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.sendAttendeeFormLink(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const getDraft = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Attendee forms saved part-way',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
response: single(z.object({ data: z.record(z.string(), z.unknown()), updatedAt: z.string().nullable() })),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getFormDraft(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const saveDraft = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Save attendee forms part-way',
|
||||
description: 'Kept for the registration\'s owner (also when staff save it), until the forms are submitted.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ data: z.record(z.string(), z.unknown()) }), response: single(z.object({ message: z.string(), updatedAt: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.saveFormDraft(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const submitForms = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Submit attendee forms',
|
||||
description: 'One response per main ticket at most; each named attendee answers once. Tickets are issued once it\'s paid and the forms are in.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, body: responsesBody,
|
||||
response: single(z.looseObject({ message: z.string(), count: z.number().int().optional() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.submitFormResponses(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const replaceForms = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Replace the attendee forms',
|
||||
description: 'Supervisors: every response on the registration is replaced by these.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, body: responsesBody, response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.replaceFormResponses(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
// ─── Staff ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const list = defineRoute({
|
||||
method: 'get', path: '/registrations', tag: 'Registrations', summary: 'Every registration (staff)',
|
||||
description: 'Newest first, for the events you may see. `unpaid=true` for ones still owing; `userId` for one person\'s.',
|
||||
access: 'staff', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100).describe('At most 200'),
|
||||
unpaid: z.enum(['true', 'false']).default('false'),
|
||||
userId: id.optional(),
|
||||
}),
|
||||
response: z.object({ data: z.array(fullRegistration), meta: z.object({ page: z.number().int(), limit: z.number().int(), total: z.number().int(), pages: z.number().int() }) }),
|
||||
async handler({ query, user, req }) {
|
||||
const out = await admin.getRegistrations(callFrom(req, user, { query: { page: String(query.page), limit: String(query.limit), unpaid: query.unpaid === 'true' ? '1' : '', userId: query.userId } }));
|
||||
return pageOf(plain(out.data), out.total, { page: out.page, limit: out.limit });
|
||||
},
|
||||
});
|
||||
|
||||
export const forEvent = defineRoute({
|
||||
method: 'get', path: '/events/:id/registrations', tag: 'Registrations', summary: 'An event\'s registrations',
|
||||
description: 'Oldest first, with tickets, payments and attendees. `search` matches the name, email or phone.',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
query: z.object({ search: z.string().max(200).optional() }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ params, query, user, req }) { return { data: plain(await admin.getRegistrationsByEvent(callFrom(req, user, { params: { eventId: params.id }, query }))) }; },
|
||||
});
|
||||
|
||||
export const setStatus = defineRoute({
|
||||
method: 'patch', path: '/registrations/:id/status', tag: 'Registrations', summary: 'Set a registration\'s status',
|
||||
description: 'Staff. `paid` issues and sends the tickets; leaving `paid` withdraws unused tickets (refused once one has been scanned); `cancelled` frees its places for the waitlist.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.object({ status: z.enum(STATUSES) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationStatus(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const manual = defineRoute({
|
||||
method: 'post', path: '/registrations/manual', tag: 'Registrations', summary: 'Register someone (at the door, by phone)',
|
||||
description: [
|
||||
'Supervisors. Finds the person by email or phone (refused when those are two different accounts) or makes an account and sends them an activation link (`skipActivationNotice` to not).',
|
||||
'Added to their existing registration for the event if they have one. Free ones are marked paid and get their tickets; otherwise they\'re sent a link to pay.',
|
||||
].join('\n\n'),
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })], status: 201,
|
||||
body: z.looseObject({
|
||||
eventId: id,
|
||||
options: z.array(lineBody).min(1).max(100),
|
||||
user: z.looseObject({ name: z.string().trim().min(1).max(200), email: z.string().max(200).nullish(), phoneNumber: z.string().max(40).nullish(), password: z.string().max(200).optional().describe('Self-service kiosk: the visitor sets it, and the account is active at once') }),
|
||||
notificationPreference: z.string().max(20).nullish().describe('email, whatsapp or both (anything else: worked out from the contact details given)'),
|
||||
skipActivationNotice: z.boolean().optional(),
|
||||
couponCode: z.string().max(40).nullish(),
|
||||
attendees: z.array(attendeeBody).max(1000).optional(),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional(),
|
||||
}),
|
||||
response: single(fullRegistration.extend({ couponError: z.string().optional() })),
|
||||
async handler({ body, user, req }) { return { data: plain(await admin.createManualRegistration(callFrom(req, user, { body }))) }; },
|
||||
});
|
||||
|
||||
export const bulk = defineRoute({
|
||||
method: 'post', path: '/registrations/bulk', tag: 'Registrations', summary: 'Do one thing to many registrations',
|
||||
description: '`markPaid`, `cancel`, `moveOption`, `allocate` or `message`, for up to 500 you may manage. Each is done on its own: the answer lists what worked and what didn\'t.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ body, req }) {
|
||||
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
|
||||
return { data: plain(await runBulk(req, body)) };
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -105,7 +109,7 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
if (def.file) {
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string | Buffer } }).data;
|
||||
res.setHeader('Content-Type', f.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
|
||||
res.status(def.status ?? 200).send(f.body);
|
||||
@@ -121,4 +125,4 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
}
|
||||
|
||||
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.union([z.string(), z.instanceof(Buffer)]) }) });
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
|
||||
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
|
||||
*
|
||||
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
|
||||
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
|
||||
* v1's `code` becomes `reason`.
|
||||
*
|
||||
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
|
||||
* 1.51.10 did) can follow later without changing the v2 API.
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
import { basename } from 'path';
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
|
||||
/** `url` (with its query string) is for v1 routers, which route by it. */
|
||||
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; url?: string };
|
||||
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
|
||||
|
||||
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
|
||||
class RecordingResponse extends Writable {
|
||||
statusCode = 200;
|
||||
headersSent = false;
|
||||
locals: Record<string, unknown> = {};
|
||||
readonly headers: Record<string, string> = {};
|
||||
private chunks: Buffer[] = [];
|
||||
json?: (v: unknown) => this;
|
||||
answer?: V1Answer;
|
||||
private done!: (a: V1Answer) => void;
|
||||
readonly finished: Promise<V1Answer>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.finished = new Promise(resolve => { this.done = resolve; });
|
||||
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
|
||||
}
|
||||
|
||||
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
|
||||
override _final(cb: () => void) { this.finishWith({}); cb(); }
|
||||
|
||||
private finishWith(extra: Partial<V1Answer>): this {
|
||||
if (this.answer) return this;
|
||||
this.headersSent = true;
|
||||
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
|
||||
this.done(this.answer);
|
||||
return this;
|
||||
}
|
||||
|
||||
status(code: number) { this.statusCode = code; return this; }
|
||||
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
|
||||
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
|
||||
set(name: string | Record<string, unknown>, value?: unknown) {
|
||||
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
|
||||
else this.setHeader(name, value);
|
||||
return this;
|
||||
}
|
||||
header(name: string, value?: unknown) { return this.set(name, value); }
|
||||
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
|
||||
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
|
||||
send(body?: unknown) {
|
||||
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
|
||||
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
|
||||
return this.finishWith({});
|
||||
}
|
||||
// A handler that ends without writing anything still answers.
|
||||
override end(...args: any[]) {
|
||||
const [chunk] = args;
|
||||
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
|
||||
this.finishWith({});
|
||||
return this;
|
||||
}
|
||||
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
|
||||
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
|
||||
const name = typeof filename === 'string' ? filename : basename(filePath);
|
||||
readFile(filePath).then(buf => {
|
||||
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
|
||||
this.setHeader('content-disposition', `attachment; filename="${name}"`);
|
||||
this.chunks.push(buf);
|
||||
this.finishWith({});
|
||||
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
sendFile(filePath: string) {
|
||||
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
redirect(a: number | string, b?: string) {
|
||||
const url = typeof a === 'string' ? a : b!;
|
||||
this.statusCode = typeof a === 'number' ? a : 302;
|
||||
return this.finishWith({ redirect: url });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
|
||||
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
|
||||
* campus scope, read-only state) is the real request's.
|
||||
*/
|
||||
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
|
||||
const fake = Object.create(req) as Request;
|
||||
for (const key of ['params', 'query', 'body', 'url'] as const) {
|
||||
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
|
||||
}
|
||||
const res = new RecordingResponse();
|
||||
const chain = Array.isArray(handlers) ? handlers : [handlers];
|
||||
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
|
||||
let advanced = false;
|
||||
const next = (err?: unknown) => {
|
||||
advanced = true;
|
||||
if (err) return reject(err);
|
||||
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
|
||||
// Nothing answered (e.g. a v1 router with no matching route).
|
||||
else reject(Object.assign(new Error('There is no such API endpoint.'), { statusCode: 404 }));
|
||||
};
|
||||
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
|
||||
});
|
||||
await Promise.race([step(0).then(() => res.finished), res.finished]);
|
||||
return res.answer!;
|
||||
}
|
||||
|
||||
/** A v1 error answer as something v2's error handler understands. */
|
||||
function v1Error(a: V1Answer): Error {
|
||||
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
|
||||
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
|
||||
const e = new Error(text) as Error & { statusCode: number; code?: string };
|
||||
e.statusCode = a.status;
|
||||
if (typeof j.code === 'string') e.code = j.code;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** `{ data: <anything> }` — for v1 answers documented by their description. */
|
||||
export const anyData = z.object({ data: z.unknown() });
|
||||
export const listData = z.object({ data: z.array(z.looseObject({})) });
|
||||
export const objectData = z.object({ data: z.looseObject({}) });
|
||||
|
||||
type V1RouteDef = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
path: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
access: Access;
|
||||
campusScope?: string;
|
||||
middleware?: RequestHandler[];
|
||||
params?: z.ZodType;
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response?: z.ZodType;
|
||||
status?: number;
|
||||
file?: { contentType: string };
|
||||
upload?: { field: string; description: string };
|
||||
/** The v1 handler (or middleware chain ending in one). */
|
||||
v1: RequestHandler | RequestHandler[];
|
||||
/**
|
||||
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
|
||||
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
|
||||
*/
|
||||
map?: (ctx: Ctx<any, any, any>) => Parts;
|
||||
};
|
||||
|
||||
/** A v2 route answered by a v1 handler. */
|
||||
export function v1Route(def: V1RouteDef) {
|
||||
const { v1, map, response, ...rest } = def;
|
||||
return defineRoute({
|
||||
...rest,
|
||||
response: def.file ? fileResponse : (response ?? anyData),
|
||||
async handler(ctx) {
|
||||
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
|
||||
const a = await runV1(v1, ctx.req, parts);
|
||||
if (a.status >= 400) throw v1Error(a);
|
||||
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
|
||||
if (def.file) {
|
||||
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
|
||||
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
|
||||
}
|
||||
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,10 @@ jest.mock('../src/utils/campusScope', () => ({
|
||||
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
|
||||
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
listWhere: async () => ({}),
|
||||
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
scopeBroadcast: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
campusIndependent: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
|
||||
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
|
||||
@@ -82,6 +86,18 @@ jest.mock('../src/services/deals', () => Object.fromEntries(Object.keys(mockDeal
|
||||
const mockCampuses = { publicCampuses: jest.fn(), campusBySlug: jest.fn(), adminCampuses: jest.fn(), setCampusesEnabled: jest.fn(), createCampus: jest.fn(), updateCampus: jest.fn(), reorderCampuses: jest.fn(), deleteCampus: jest.fn(), campusAudience: jest.fn() };
|
||||
jest.mock('../src/services/campuses', () => Object.fromEntries(Object.keys(mockCampuses).map(k => [k, (...a: unknown[]) => (mockCampuses as Record<string, jest.Mock>)[k](...a)])));
|
||||
|
||||
const mockAdmin = { getRegistrations: jest.fn(), getUserRegistrations: jest.fn(), getRegistrationById: jest.fn(), updateRegistrationStatus: jest.fn(), getRegistrationsByEvent: jest.fn(), createManualRegistration: jest.fn(), updateRegistrationOptions: jest.fn(), submitFormResponses: jest.fn(), replaceFormResponses: jest.fn(), getFormDraft: jest.fn(), saveFormDraft: jest.fn(), sendInvoice: jest.fn(), applyCoupon: jest.fn(), removeCoupon: jest.fn(), updateAttendees: jest.fn(), sendAttendeeFormLink: jest.fn() };
|
||||
jest.mock('../src/services/registrationAdmin', () => Object.fromEntries(Object.keys(mockAdmin).map(k => [k, (...a: unknown[]) => (mockAdmin as Record<string, jest.Mock>)[k](...a)])));
|
||||
jest.mock('../src/utils/seasonPass', () => ({ notOnSeasonPass: (_req: unknown, _res: unknown, next: () => void) => next() }));
|
||||
|
||||
jest.mock('../src/controllers/cashupController', () => {
|
||||
const echo = (req: { params: object; body: object }, res: { json(b: unknown): void }) => res.json({ params: req.params, body: req.body });
|
||||
return {
|
||||
getEventCashup: echo, getCashByRecordedUser: echo, savePersonCash: echo, saveEventCashupDraft: echo, reopenEvent: echo, resolvePending: echo, getCashupAudit: echo,
|
||||
closeEvent: (_req: unknown, res: { status(n: number): { json(b: unknown): void } }) => res.status(400).json({ message: 'There are pending lines.', code: 'PENDING_LINES' }),
|
||||
};
|
||||
});
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
@@ -602,3 +618,78 @@ describe('series, deals and campuses', () => {
|
||||
expect((await request(app).put('/api/v2/campuses/enabled').send({ enabled: true })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managing registrations', () => {
|
||||
const reg = { id: 'r1', status: 'pending', eventId: 'e1', userId: 'u1', totalDueComputed: 150, payments: [] };
|
||||
|
||||
test('a guest with the link can read a registration in full', async () => {
|
||||
mockAdmin.getRegistrationById.mockResolvedValue(reg);
|
||||
const res = await request(app).get('/api/v2/registrations/r1/full');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', totalDueComputed: 150, payments: [] });
|
||||
expect(mockAdmin.getRegistrationById.mock.calls[0][0]).toMatchObject({ actor: null, params: { id: 'r1' } });
|
||||
});
|
||||
|
||||
test('the staff list is paged and campus-scoped', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/registrations')).status).toBe(403);
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockAdmin.getRegistrations.mockResolvedValue({ data: [reg], total: 1, page: 1, limit: 100, pages: 1 });
|
||||
const res = await request(app).get('/api/v2/registrations?unpaid=true');
|
||||
expect(res.body).toEqual({ data: [reg], meta: { page: 1, limit: 100, total: 1, pages: 1 } });
|
||||
const call = mockAdmin.getRegistrations.mock.calls[0][0];
|
||||
expect(call.query).toMatchObject({ unpaid: '1', page: '1', limit: '100' });
|
||||
expect(typeof call.listWhere).toBe('function');
|
||||
});
|
||||
|
||||
test('a status must be one of the five', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
const res = await request(app).patch('/api/v2/registrations/r1/status').send({ status: 'refunded' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe('VALIDATION_FAILED');
|
||||
expect(mockAdmin.updateRegistrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('registering someone at the door is for supervisors, and answers 201', async () => {
|
||||
const body = { eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 2 }], user: { name: 'Thandi', phoneNumber: '0821234567' } };
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
expect((await request(app).post('/api/v2/registrations/manual').send(body)).status).toBe(403);
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockAdmin.createManualRegistration.mockResolvedValue(reg);
|
||||
const res = await request(app).post('/api/v2/registrations/manual').send(body);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAdmin.createManualRegistration.mock.calls[0][0].body).toMatchObject(body);
|
||||
});
|
||||
|
||||
test("a refusal keeps the service's status", async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockAdmin.updateRegistrationOptions.mockRejectedValueOnce(new ServiceError(400, 'Cannot reduce items below the amount already paid'));
|
||||
const res = await request(app).put('/api/v2/registrations/r1/items').send({ options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatchObject({ code: 'VALIDATION_FAILED', message: 'Cannot reduce items below the amount already paid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('money (v1 handlers behind v2 routes)', () => {
|
||||
test("an event's cash-up reaches v1 with the event as :eventId", async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
const res = await request(app).get('/api/v2/events/e1/cashup');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.params).toMatchObject({ eventId: 'e1' });
|
||||
});
|
||||
|
||||
test('closing is for admins, and a v1 refusal keeps its code as the reason', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).post('/api/v2/events/e1/cashup/close').send({})).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/events/e1/cashup/close').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toEqual({ code: 'VALIDATION_FAILED', message: 'There are pending lines.', reason: 'PENDING_LINES' });
|
||||
});
|
||||
|
||||
test('a cash-up line is given to v1 as :lineId', async () => {
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/cashup-lines/l9/pending').send({ resolution: 'paid' });
|
||||
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { PassThrough } from 'stream';
|
||||
import { z } from 'zod';
|
||||
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
jest.mock('../src/middleware/authMiddleware', () => ({ protect: jest.fn(), optionalAuth: jest.fn(), admin: jest.fn(), staff: jest.fn(), supervisor: jest.fn() }));
|
||||
|
||||
import { mount } from '../src/v2/route';
|
||||
import { errorHandler } from '../src/v2/errors';
|
||||
import { v1Route } from '../src/v2/viaV1';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
function appWith(...defs: ReturnType<typeof v1Route>[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
mount(router, defs);
|
||||
app.use('/api/v2', router);
|
||||
app.use('/api/v2', errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('v2 routes answered by v1 handlers', () => {
|
||||
test("v1's JSON becomes data; v2's :id is given to v1 by its old name", async () => {
|
||||
const handler: RequestHandler = (req, res) => { res.json({ eventId: req.params.eventId, q: req.query.x }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/things/:id', tag: 'T', summary: 's', access: 'public', v1: handler, map: ctx => ({ params: { eventId: ctx.params.id } }), params: z.object({ id: z.string() }) }));
|
||||
const res = await request(app).get('/api/v2/things/e1?x=2');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { eventId: 'e1', q: '2' } });
|
||||
});
|
||||
|
||||
test("a v1 error is a v2 error, with v1's code as the reason", async () => {
|
||||
const handler: RequestHandler = (_req, res) => { res.status(409).json({ message: 'Already closed.', code: 'EVENT_CLOSED' }); };
|
||||
const app = appWith(v1Route({ method: 'post', path: '/close', tag: 'T', summary: 's', access: 'public', v1: handler }));
|
||||
const res = await request(app).post('/api/v2/close').send({});
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: { code: 'CONFLICT', message: 'Already closed.', reason: 'EVENT_CLOSED' } });
|
||||
});
|
||||
|
||||
test('a thrown error and a next(err) both reach the error handler', async () => {
|
||||
const thrower: RequestHandler = async () => { throw Object.assign(new Error('Nope.'), { statusCode: 403 }); };
|
||||
const passer: RequestHandler = (_req, _res, next) => next(Object.assign(new Error('Missing.'), { statusCode: 404 }));
|
||||
const app = appWith(
|
||||
v1Route({ method: 'get', path: '/a', tag: 'T', summary: 's', access: 'public', v1: thrower }),
|
||||
v1Route({ method: 'get', path: '/b', tag: 'T', summary: 's', access: 'public', v1: passer }),
|
||||
);
|
||||
expect((await request(app).get('/api/v2/a')).body.error).toMatchObject({ code: 'FORBIDDEN', message: 'Nope.' });
|
||||
expect((await request(app).get('/api/v2/b')).body.error).toMatchObject({ code: 'NOT_FOUND', message: 'Missing.' });
|
||||
});
|
||||
|
||||
test('middleware chains run in order', async () => {
|
||||
const mark: RequestHandler = (req, _res, next) => { (req as unknown as { mark: string }).mark = 'yes'; next(); };
|
||||
const handler: RequestHandler = (req, res) => { res.json({ mark: (req as unknown as { mark: string }).mark }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/m', tag: 'T', summary: 's', access: 'public', v1: [mark, handler] }));
|
||||
expect((await request(app).get('/api/v2/m')).body).toEqual({ data: { mark: 'yes' } });
|
||||
});
|
||||
|
||||
test('a file a v1 handler pipes is sent as a download', async () => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="ticket.pdf"');
|
||||
const s = new PassThrough();
|
||||
s.pipe(res);
|
||||
s.write(Buffer.from('%PDF-1.4 '));
|
||||
s.end(Buffer.from('end'));
|
||||
};
|
||||
const app = appWith(v1Route({ method: 'get', path: '/pdf', tag: 'T', summary: 's', access: 'public', file: { contentType: 'application/pdf' }, v1: handler }));
|
||||
const res = await request(app).get('/api/v2/pdf').buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on('data', (d: Buffer) => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('ticket.pdf');
|
||||
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
|
||||
});
|
||||
|
||||
test("a v1 router is given the v1 URL; one with no matching route is a 404", async () => {
|
||||
const router = express.Router();
|
||||
router.get('/today', (req, res) => { res.json({ q: req.query.x }); });
|
||||
const app = appWith(
|
||||
v1Route({ method: 'get', path: '/dash', tag: 'T', summary: 's', access: 'public', v1: router, map: ctx => ({ url: '/today?x=' + String(ctx.req.query.x) }) }),
|
||||
v1Route({ method: 'get', path: '/nothing', tag: 'T', summary: 's', access: 'public', v1: router, map: () => ({ url: '/missing' }) }),
|
||||
);
|
||||
expect((await request(app).get('/api/v2/dash?x=7')).body).toEqual({ data: { q: '7' } });
|
||||
const res = await request(app).get('/api/v2/nothing');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
test("a file v1 sends with res.download is a download", async () => {
|
||||
const os = require("os"); const path = require("path");
|
||||
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
|
||||
require("fs").writeFileSync(file, "-- backup");
|
||||
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
|
||||
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
|
||||
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-disposition"]).toContain("backup.sql");
|
||||
expect((res.body as Buffer).toString()).toBe("-- backup");
|
||||
});
|
||||
});
|
||||
+39
-6
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.9** (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,12 @@ 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()` |
|
||||
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
|
||||
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
|
||||
@@ -69,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.10 – 1.51.15: 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.15 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
|
||||
@@ -467,6 +472,34 @@ a test server (both done 2026-10-28).
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## v1 handlers behind v2 routes (1.51.11 –)
|
||||
|
||||
- **`v1Route({ …defineRoute options, v1: handler, map? })`** (`src/v2/viaV1.ts`) answers a v2 route
|
||||
with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with
|
||||
params/query/body replaced by `map(ctx)` — e.g. `map: ctx => ({ params: { eventId: ctx.params.id } })`
|
||||
where v1 named it `:eventId`. The same campus middleware goes in `middleware`.
|
||||
- v1 JSON → `{ data }`; a 4xx/5xx → the v2 error (v1 `code` → `reason`); a redirect →
|
||||
`{ data: { redirectUrl } }`; with `file`, whatever was sent or piped → a download.
|
||||
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
|
||||
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
|
||||
only allows declared ones.
|
||||
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
|
||||
doesn't change when it happens.
|
||||
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
|
||||
in the repo); calls with a computed URL are done by hand.
|
||||
|
||||
## Managing registrations on v2 (1.51.10)
|
||||
|
||||
- **`registrationAdmin.ts` takes a `Call`** — `{ actor, params, body, query, readOnly, ip, listWhere }`
|
||||
— and returns what v1 answered, because these handlers were moved nearly as they were (about
|
||||
1,400 lines). v1 builds it with `callOf(req)`, v2 with `callFrom(req, user, parts)` in
|
||||
`resources/registrationsManage.ts`. Lists that are campus-scoped call `call.listWhere()`, which
|
||||
refuses (500) when a caller didn't give one, so nothing is listed unscoped by mistake.
|
||||
- Errors a JS helper throws without a status were 400s on v1; `as400` keeps them 400 on v2
|
||||
(TypeErrors and the like stay 500s).
|
||||
- The staff list is paged on v2 (up to 200 a page); screens that need everything use
|
||||
`fetchAllRegistrations(token, { unpaid?, userId? })`.
|
||||
|
||||
## Series, deals and campuses on v2 (1.51.9)
|
||||
|
||||
- **`apiV2Data(method, path, opts)`** (`lib/apiV2.ts`) returns `data` untyped for screens that keep
|
||||
@@ -486,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.13).
|
||||
email/WhatsApp senders — all on v2 since 1.51.14 (`v1Route`).
|
||||
|
||||
## Setting events up on v2 (1.51.7)
|
||||
|
||||
|
||||
+6
-4
@@ -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** shipped. **Next: 1.51.10 – 1.51.15 (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.9 ✅, 1.51.10 – 1.51.15 | **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 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.11 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.12 kids check-in and volunteer rosters; 1.51.13 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.14 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.15 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 11b | 1.51.4 ✅ – 1.51.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/`),
|
||||
@@ -1050,7 +1052,7 @@ feature.
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.15, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.15); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.16, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.16); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
|
||||
+144
-2
@@ -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 }) => {
|
||||
@@ -183,12 +184,153 @@ test.describe("API v2", () => {
|
||||
expect((await request.get(`${API}/api/v2/campuses/admin`, { headers: sup })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("staff register someone at the door and manage the registration", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
const opt = ev.eventOptions[0];
|
||||
|
||||
const made = await request.post(`${API}/api/v2/registrations/manual`, {
|
||||
headers: sup,
|
||||
data: { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }], user: { name: "E2E Door Guest", email: "e2e-v2-door@example.com" }, skipActivationNotice: true },
|
||||
});
|
||||
expect(made.status()).toBe(201);
|
||||
const reg = (await made.json()).data;
|
||||
expect(reg).toMatchObject({ eventId: ev.id, status: expect.any(String) });
|
||||
|
||||
const list = (await (await request.get(`${API}/api/v2/events/${ev.id}/registrations?search=e2e-v2-door`, { headers: sup })).json()).data;
|
||||
expect(list.map((r: { id: string }) => r.id)).toContain(reg.id);
|
||||
|
||||
const full = (await (await request.get(`${API}/api/v2/registrations/${reg.id}/full`, { headers: sup })).json()).data;
|
||||
expect(full.registrationOptions[0]).toMatchObject({ eventOptionId: opt.id, quantity: 1 });
|
||||
|
||||
const bad = await request.patch(`${API}/api/v2/registrations/${reg.id}/status`, { headers: sup, data: { status: "refunded" } });
|
||||
expect(bad.status()).toBe(400);
|
||||
expect((await bad.json()).error.code).toBe("VALIDATION_FAILED");
|
||||
|
||||
const page = await (await request.get(`${API}/api/v2/registrations?limit=2`, { headers: sup })).json();
|
||||
expect(page.meta).toMatchObject({ page: 1, limit: 2 });
|
||||
expect(page.data.length).toBeLessThanOrEqual(2);
|
||||
|
||||
// Tidy up: cancelled registrations don't count towards anything.
|
||||
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: admin });
|
||||
expect((await cancelled.json()).data.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
test("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const providers = await request.get(`${API}/api/v2/payments/providers`);
|
||||
expect(providers.status()).toBe(200);
|
||||
expect(await providers.json()).toHaveProperty("data");
|
||||
|
||||
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
|
||||
expect(cash.status()).toBe(200);
|
||||
expect((await cash.json()).data).toBeTruthy();
|
||||
|
||||
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
|
||||
expect(mine.status()).toBe(200);
|
||||
|
||||
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
|
||||
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
|
||||
|
||||
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
|
||||
const res = await request.get(`${API}${path}`);
|
||||
expect(res.status(), path).toBeGreaterThanOrEqual(400);
|
||||
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
}
|
||||
});
|
||||
|
||||
test("tickets and attendance: mine, the register, a spreadsheet download, links that don't exist", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const mine = await request.get(`${API}/api/v2/me/tickets`, { headers: user });
|
||||
expect(mine.status()).toBe(200);
|
||||
expect(Array.isArray((await mine.json()).data)).toBe(true);
|
||||
|
||||
const reg = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance`, { headers: staff });
|
||||
expect(reg.status()).toBe(200);
|
||||
|
||||
const xlsx = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance/export?format=xlsx`, { headers: staff });
|
||||
expect(xlsx.status()).toBe(200);
|
||||
expect(xlsx.headers()["content-type"]).toContain("spreadsheetml");
|
||||
expect(xlsx.headers()["content-disposition"]).toContain(".xlsx");
|
||||
expect((await xlsx.body()).subarray(0, 2).toString()).toBe("PK");
|
||||
|
||||
// A code that isn't a certificate is an answer, not an error.
|
||||
expect((await (await request.get(`${API}/api/v2/certificates/verify/NO-SUCH-CODE`)).json()).data).toEqual({ valid: false });
|
||||
const transfer = await request.get(`${API}/api/v2/ticket-transfers/not-a-real-token`);
|
||||
expect(transfer.status()).toBeGreaterThanOrEqual(400);
|
||||
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
});
|
||||
|
||||
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
|
||||
const res = await request.get(`${API}${path}`, { headers });
|
||||
expect(res.status(), path).toBe(200);
|
||||
expect(await res.json(), path).toHaveProperty("data");
|
||||
}
|
||||
// Staff lists aren't for everyone.
|
||||
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
|
||||
expect(Array.isArray(listing)).toBe(true);
|
||||
expect(listing[0]).toHaveProperty("eventOptions");
|
||||
|
||||
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
|
||||
const res = await request.get(`${API}${path}`, { headers: staff });
|
||||
expect(res.status(), path).toBe(200);
|
||||
}
|
||||
|
||||
// Saved filters run the v1 router: save one, list it, delete it.
|
||||
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
|
||||
expect(saved.status()).toBe(200);
|
||||
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
|
||||
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
|
||||
expect(mine).toBeTruthy();
|
||||
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
|
||||
|
||||
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
|
||||
expect(users).toMatchObject({ page: 1, limit: 2 });
|
||||
// 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);
|
||||
|
||||
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
|
||||
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
|
||||
// Sections start collapsed (there are hundreds of endpoints): open Events.
|
||||
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
|
||||
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
|
||||
// The token isn't left in the address bar.
|
||||
expect(page.url()).not.toContain("token=");
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.9",
|
||||
"version": "1.51.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.9",
|
||||
"version": "1.51.15",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.9",
|
||||
"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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,9 +3,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
@@ -39,7 +40,7 @@ export default function ConsentPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/consent/{token}", { params: { token: token } }) as Promise<ConsentInfo>)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
@@ -50,8 +51,8 @@ export default function ConsentPage() {
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/consent/{token}", {
|
||||
params: { token: token },
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
@@ -131,7 +131,7 @@ function ImpersonationSessions({ token }: { token: string }) {
|
||||
const [sessions, setSessions] = useState<ImpSession[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
|
||||
(apiV2Data("get", "/admin/impersonation-sessions", { query: { limit: "20" }, authToken: token }) as Promise<ImpSession[]>).then(setSessions).catch(() => setSessions([]));
|
||||
}, [token]);
|
||||
if (sessions.length === 0) return null;
|
||||
return (
|
||||
@@ -196,7 +196,7 @@ export default function AdminAuditLogPage() {
|
||||
if (search) qs.set("search", search);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
return Object.fromEntries(qs);
|
||||
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
|
||||
|
||||
// Search as you type, once typing pauses.
|
||||
@@ -215,7 +215,7 @@ export default function AdminAuditLogPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
||||
const res = await (apiV2Data("get", "/admin/audit-log", { query: buildQuery(p), authToken: token }) as Promise<{ rows: AuditLogEntry[]; total: number; page: number }>);
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -42,7 +43,7 @@ export default function EventCashupPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
(apiV2Data("get", "/events/{id}/cashup", { params: { id: eventId }, authToken: token }) as Promise<EventFinancials>),
|
||||
fetchEvent<Event>(eventId, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
@@ -309,7 +310,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
(apiV2Data("get", "/events/{id}/cashup/cash-by-user", { params: { id: eventId }, authToken: token }) as Promise<AccountabilityResponse>)
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -458,8 +459,10 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
const save = async () => {
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
await apiV2Data("put", "/events/{id}/cashup/person-cash/{userId}", {
|
||||
params: { id: eventId, userId: userId },
|
||||
authToken: token,
|
||||
body: { denominations: lines, notes: notes || null },
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -833,7 +836,7 @@ function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod;
|
||||
if (!r.lineId || !mode) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
await apiV2Data("post", "/cashup-lines/{id}/pending", { params: { id: r.lineId }, authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
setMode(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
@@ -972,7 +975,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
await apiV2Data("put", "/events/{id}/cashup/draft", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
@@ -992,7 +995,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1006,7 +1009,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1020,7 +1023,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/reopen", { params: { id: eventId }, authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Pager } from "@/components/shared/Pager";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Inbox, AlertTriangle } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type RequestType = "access" | "correction" | "deletion";
|
||||
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
|
||||
@@ -44,8 +44,8 @@ function OpenRequest({ request, token, onDone }: { request: DataRequest; token:
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", mode === "reject" ? "/privacy/requests/{id}/reject" : "/privacy/requests/{id}/complete", {
|
||||
params: { id: request.id },
|
||||
authToken: token,
|
||||
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export default function DataRequestsPage() {
|
||||
try {
|
||||
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
|
||||
if (search) qs.set("search", search);
|
||||
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/privacy/requests", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ requests: DataRequest[]; openCount: number; total: number }>);
|
||||
setRequests(res.requests);
|
||||
setOpenCount(res.openCount);
|
||||
setTotal(res.total ?? res.requests.length);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats } from "@/types";
|
||||
import { CampusFilter } from "@/components/shared/CampusFilter";
|
||||
import { TodayPanel } from "@/components/dashboard/TodayPanel";
|
||||
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -93,9 +93,9 @@ export default function AdminDashboardPage() {
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [dash, overviewData] = await Promise.all([
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
(apiV2Data("get", "/stats/admin", { authToken: token }) as Promise<DashboardStats>),
|
||||
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
|
||||
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
|
||||
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
|
||||
]);
|
||||
setPaymentStats(dash.paymentStats);
|
||||
setOverview(overviewData);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -12,6 +12,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -80,7 +81,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token);
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
@@ -124,7 +125,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const res = await (apiV2Data("get", "/form-responses", { query: { registrationId: id }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
@@ -140,7 +141,7 @@ export default function AdminRegistrationsPage() {
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
await apiV2Data("post", "/registrations/{id}/cancel", { params: { id: reg.id }, authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
@@ -152,7 +153,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
await apiV2Data("patch", "/registrations/{id}/status", { params: { id: reg.id }, authToken: token, body: { status: status as "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled" } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch, ApiError, 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." });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -19,7 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -265,20 +265,20 @@ export default function AtTheDoorPage() {
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
// Edit existing registration — replace options via PUT
|
||||
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { options: opts }
|
||||
});
|
||||
let res = await (apiV2Data("put", "/registrations/{id}/items", {
|
||||
params: { id: pendingEditReg.id },
|
||||
authToken: token,
|
||||
body: { options: opts },
|
||||
}) as Promise<Registration>);
|
||||
let message = "Registration updated";
|
||||
if (doorCoupon) {
|
||||
// Applied after the items are saved, so it's worked out on the final items.
|
||||
try {
|
||||
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
});
|
||||
res = await (apiV2Data("post", "/registrations/{id}/coupon", {
|
||||
params: { id: pendingEditReg.id },
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
}) as Promise<Registration>);
|
||||
message = `Registration updated — code ${doorCoupon.code} applied`;
|
||||
} catch (ce) {
|
||||
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
|
||||
@@ -290,10 +290,9 @@ export default function AtTheDoorPage() {
|
||||
handleRegistrationSelected(res);
|
||||
} else if (pendingUser) {
|
||||
// New attendee
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
user: {
|
||||
name: pendingUser.name,
|
||||
@@ -306,8 +305,8 @@ export default function AtTheDoorPage() {
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
},
|
||||
}) as Promise<Registration & { couponError?: string }>);
|
||||
setShowOptionsModal(false);
|
||||
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
|
||||
handleRegistrationCreated(res);
|
||||
@@ -360,7 +359,7 @@ export default function AtTheDoorPage() {
|
||||
const handleEditRegistration = async (reg: Registration) => {
|
||||
let freshReg = reg;
|
||||
try {
|
||||
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
freshReg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: reg.id }, authToken: token }) as Promise<Registration>);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load latest registration data");
|
||||
return;
|
||||
@@ -575,7 +574,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
if (!cancelled) setAllRegs(regs || []);
|
||||
} catch {
|
||||
if (!cancelled) setAllRegs([]);
|
||||
@@ -601,7 +600,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
setAllRegs(regs || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}}
|
||||
@@ -705,19 +704,19 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: registration.id,
|
||||
amount: parseFloat(amount),
|
||||
method
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
|
||||
authToken: token
|
||||
});
|
||||
const updated = await (apiV2Data("get", "/registrations/{id}/full", {
|
||||
params: { id: registration.id },
|
||||
authToken: token,
|
||||
}) as Promise<Registration>);
|
||||
|
||||
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
|
||||
|
||||
@@ -886,7 +885,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
@@ -920,7 +919,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
@@ -1044,7 +1043,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
|
||||
const tickets = ticketsFrom(res);
|
||||
setTicketsForEvent(tickets.filter((t) => t.registrationOption?.eventOption?.isMainTicket));
|
||||
} catch {
|
||||
@@ -1426,16 +1425,15 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
method,
|
||||
userId: user?.id || undefined,
|
||||
eventId,
|
||||
isDonation: true
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
@@ -1538,10 +1536,9 @@ function SendTicketsModal({ open, onClose, token, registration }: {
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await apiFetch("/api/tickets/send-to", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/tickets/send", {
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: registration?.id,
|
||||
channel,
|
||||
overrideEmail: email.trim() || undefined,
|
||||
@@ -1665,19 +1662,18 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
setSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
await apiFetch("/api/payments/refund", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments/refunds", {
|
||||
authToken: token,
|
||||
body: {
|
||||
userId: registration.userId,
|
||||
registrationId: registration.id,
|
||||
amount: amt,
|
||||
method,
|
||||
reason: reason || undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
// Re-fetch registration to get updated status & payments
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
const updated = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registration.id }, authToken: token }) as Promise<Registration>);
|
||||
onRefunded(updated);
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Refund failed.");
|
||||
@@ -1778,7 +1774,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
setAllRegs(regs || []);
|
||||
} catch {
|
||||
setAllRegs([]);
|
||||
@@ -1800,7 +1796,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
setSelectedReg(updated);
|
||||
setInfo("Refund recorded");
|
||||
// Refresh list
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>).catch(() => null);
|
||||
if (regs) setAllRegs(regs);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from "reac
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
|
||||
import { API_BASE, fetchManagedEvents } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { fetchApiFile, saveBlob } from "@/lib/export";
|
||||
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
|
||||
|
||||
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
|
||||
|
||||
@@ -57,7 +58,7 @@ function AttendancePageInner() {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
setReg(await (apiV2Data("get", "/events/{id}/attendance", { params: { id: eventId }, authToken: token }) as Promise<AttendanceRegister>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the register");
|
||||
} finally {
|
||||
@@ -93,7 +94,7 @@ function AttendancePageInner() {
|
||||
try {
|
||||
// Picked sessions have a ticket per session.
|
||||
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
|
||||
await apiV2Data("post", "/events/{id}/attendance", { params: { id: eventId }, authToken: token, body: { ticketId, sessionId, present } });
|
||||
const col = reg?.columns.find(c => c.id === sessionId);
|
||||
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
|
||||
await load();
|
||||
@@ -108,7 +109,7 @@ function AttendancePageInner() {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
|
||||
const r = await apiV2Raw<Record<string, number>>("post", path, { authToken: token, body });
|
||||
setInfo(done(r));
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -173,8 +174,8 @@ function AttendancePageInner() {
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,9 +192,9 @@ function AttendancePageInner() {
|
||||
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
|
||||
<Award className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
|
||||
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
|
||||
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
|
||||
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
|
||||
<button type="button" disabled={busy} onClick={() => post(`/events/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
|
||||
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/events/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
|
||||
<button type="button" disabled={issued === 0} onClick={() => download(`/api/v2/events/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -248,12 +249,12 @@ function AttendancePageInner() {
|
||||
{r.certificate && !r.certificate.revokedAt ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700">{r.certificate.code}</span>
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
|
||||
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
|
||||
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/v2/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
|
||||
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
|
||||
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
|
||||
</div>
|
||||
) : canManage ? (
|
||||
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
|
||||
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
|
||||
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
|
||||
</button>
|
||||
) : <span className="text-xs text-gray-400">—</span>}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
@@ -38,7 +39,8 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
type ConsentData = { event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] };
|
||||
const [data, setData] = useState<ConsentData | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -60,7 +62,7 @@ export default function ConsentTrackerPage() {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/events/{id}/consent", { params: { id: eventId }, authToken: token }) as Promise<ConsentData>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load consent status");
|
||||
} finally {
|
||||
@@ -79,10 +81,10 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
|
||||
|
||||
const act = async (fn: () => Promise<{ message: string }>) => {
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
const res = (await fn()) as { message: string };
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -92,14 +94,14 @@ export default function ConsentTrackerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
|
||||
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
|
||||
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
|
||||
const resendOne = (r: ConsentRow) => act(() => apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: r.request!.id }, authToken: token, body: {} }));
|
||||
const resendAll = () => act(() => apiV2Data("post", "/events/{id}/consent/resend-pending", { params: { id: eventId }, authToken: token, body: {} }));
|
||||
const rerequest = () => act(() => apiV2Data("post", "/events/{id}/consent/rerequest", { params: { id: eventId }, authToken: token, body: {} }));
|
||||
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const res = await (apiV2Data("get", "/events/{id}/consent/medical", { params: { id: eventId }, authToken: token }) as Promise<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>);
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
|
||||
@@ -3,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";
|
||||
@@ -13,6 +13,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -279,7 +280,7 @@ function EmailAttendeesPageInner() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "email" }, authToken: token }) as Promise<{jobs: ScheduledJob[]}>);
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch {
|
||||
// ignore here; surfaces via UI when tab open
|
||||
@@ -314,7 +315,7 @@ function EmailAttendeesPageInner() {
|
||||
body.html = '';
|
||||
}
|
||||
}
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body });
|
||||
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
|
||||
setInfo('Scheduled email updated.');
|
||||
setEditing(null);
|
||||
loadScheduled();
|
||||
@@ -328,7 +329,7 @@ function EmailAttendeesPageInner() {
|
||||
const removeJob = async (job: ScheduledJob) => {
|
||||
try {
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
|
||||
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
|
||||
setInfo('Scheduled email removed.');
|
||||
loadScheduled();
|
||||
} catch (e) {
|
||||
@@ -390,7 +391,7 @@ function EmailAttendeesPageInner() {
|
||||
setAttendees([]);
|
||||
setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
@@ -431,7 +432,7 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
|
||||
}
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(matchedText(res));
|
||||
@@ -472,7 +473,7 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
|
||||
const queued = res?.queued ?? res?.matched ?? 0;
|
||||
setInfo(`Queued ${queued} recipient(s) for sending.`);
|
||||
// Reset form to default state
|
||||
@@ -669,7 +670,7 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await (apiV2Data("post", "/events/{id}/email-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
|
||||
if (res?.job?.id) {
|
||||
setInfo('Email scheduled. It will be sent around the specified time.');
|
||||
} else {
|
||||
@@ -778,7 +779,7 @@ Jane Doe <jane@example.com>
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined, kind: broadcastKind };
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
|
||||
const res = await (apiV2Data("post", "/broadcasts/email/preview", { authToken: token, body }) as Promise<EmailSendResult>);
|
||||
setBroadcastPreviewCount(res?.matched ?? 0);
|
||||
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(matchedText(res));
|
||||
@@ -795,7 +796,7 @@ Jane Doe <jane@example.com>
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await (apiV2Data("post", "/broadcasts/email/send", { authToken: token, body: payload }) as Promise<EmailSendResult>);
|
||||
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ''}`);
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
@@ -813,7 +814,7 @@ Jane Doe <jane@example.com>
|
||||
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
|
||||
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await (apiV2Data("post", "/broadcasts/email/schedule", { authToken: token, body: payload }) as Promise<EmailSendResult>);
|
||||
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
@@ -1084,7 +1085,7 @@ With gratitude`);
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
|
||||
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
|
||||
const res = await (apiV2Data("post", "/automations/schedule", { authToken: token, body: { eventId: autoEventId, jobs } }) as Promise<{ message?: string }>);
|
||||
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
|
||||
// Reset to defaults (keep event selection)
|
||||
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
|
||||
|
||||
@@ -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([]);
|
||||
@@ -1077,7 +1077,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
|
||||
useEffect(() => {
|
||||
if (!feedbackFrom || !token) return;
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
|
||||
(apiV2Data("get", "/events/{id}/feedback", { params: { id: feedbackFrom }, authToken: token }) as Promise<FeedbackFormResponse | null>).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
if (cloning) {
|
||||
@@ -1111,8 +1111,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
};
|
||||
const saveFeedback = async (eventId: string) => {
|
||||
if (fbEnabled) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
await apiV2Data("put", "/events/{id}/feedback", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: {
|
||||
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
|
||||
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
|
||||
@@ -1120,7 +1121,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
},
|
||||
});
|
||||
} else if (fbMeta) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
|
||||
await apiV2Data("delete", "/events/{id}/feedback", { params: { id: eventId }, authToken: token });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1693,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 } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,14 +4,14 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
@@ -221,8 +221,7 @@ export default function FormsBrowserPage() {
|
||||
if (userId.trim()) qs.set("userId", userId.trim());
|
||||
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
|
||||
if (append && nextCursor) qs.set("cursor", nextCursor);
|
||||
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
|
||||
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/form-responses", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ items: FormResponse[]; nextCursor?: string }>);
|
||||
const newItems = Array.isArray(res?.items) ? res!.items : [];
|
||||
setItems(prev => append ? [...prev, ...newItems] : newItems);
|
||||
setNextCursor(res?.nextCursor || null);
|
||||
@@ -350,7 +349,7 @@ export default function FormsBrowserPage() {
|
||||
if (!token || !evId) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: evId }, authToken: token }) as Promise<Registration[]>);
|
||||
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
|
||||
setFillRegistrations(sorted);
|
||||
} catch {
|
||||
@@ -374,13 +373,13 @@ export default function FormsBrowserPage() {
|
||||
(async () => {
|
||||
if (mode !== 'fill' || !fillRegistrationId) return;
|
||||
try {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: fillRegistrationId }, authToken: token }) as Promise<Registration>);
|
||||
setFillRegistration(reg);
|
||||
if (reg?.event?.id) {
|
||||
const evFull = await fetchEvent<Event>(reg.event.id);
|
||||
setFillForm(toFormDef(evFull?.form));
|
||||
}
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration/form');
|
||||
@@ -435,11 +434,13 @@ export default function FormsBrowserPage() {
|
||||
if (v == null || String(v).trim() === '') throw new Error(`Response #${i+1}: Missing answer for a required field`);
|
||||
}
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(fillRegistrationId)}/forms/responses`, {
|
||||
method: 'PUT', authToken: token, body: { responses: editableResponses }
|
||||
await apiV2Data("put", "/registrations/{id}/form-responses", {
|
||||
params: { id: fillRegistrationId },
|
||||
authToken: token,
|
||||
body: { responses: editableResponses },
|
||||
});
|
||||
setInfo('Responses saved');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save responses');
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Baby, Plus, Trash2 } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
|
||||
type Settings = {
|
||||
@@ -34,13 +34,13 @@ export default function KidsSetupPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
(apiV2Data("get", "/kids/setup-events", { authToken: token }) as Promise<SetupEvent[]>).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
|
||||
const data = await (apiV2Data("get", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>);
|
||||
setS(data);
|
||||
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
|
||||
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
|
||||
@@ -64,16 +64,16 @@ export default function KidsSetupPage() {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
|
||||
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiV2Data("put", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token, body }), ok);
|
||||
|
||||
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
|
||||
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
|
||||
const addRoom = () => s?.event.kidsRoomSetId && run(
|
||||
() => apiFetch(`/api/allocations/sets/${s.event.kidsRoomSetId}/groups`, { method: "POST", authToken: token, body: roomBody(newRoom) }),
|
||||
() => apiV2Data("post", "/allocation-sets/{id}/groups", { params: { id: s.event.kidsRoomSetId! }, authToken: token, body: roomBody(newRoom) }),
|
||||
"Room added.",
|
||||
).then(ok => { if (ok) setNewRoom({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" }); });
|
||||
const saveRoom = (id: string) => run(() => apiFetch(`/api/allocations/groups/${id}`, { method: "PUT", authToken: token, body: roomBody(draft[id]) }), "Room saved.");
|
||||
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiFetch(`/api/allocations/groups/${r.id}`, { method: "DELETE", authToken: token }), "Room deleted.");
|
||||
const saveRoom = (id: string) => run(() => apiV2Data("patch", "/allocation-groups/{id}", { params: { id: id }, authToken: token, body: roomBody(draft[id]) }), "Room saved.");
|
||||
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiV2Data("delete", "/allocation-groups/{id}", { params: { id: r.id }, authToken: token }), "Room deleted.");
|
||||
|
||||
const roomRow = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }, set: (v: typeof d) => void) => (
|
||||
<>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -39,15 +40,14 @@ export default function ManualRegistrationPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
options: [{ eventOptionId: optionId, quantity }],
|
||||
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
||||
},
|
||||
});
|
||||
}) as Promise<Registration>);
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
@@ -154,8 +154,10 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
setError(null); setInfo(null);
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
|
||||
method: 'POST', authToken: token, body: { responses: payload }
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registration.id },
|
||||
authToken: token,
|
||||
body: { responses: payload },
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { fetchAllUsers, fetchManagedEvents, fetchEvent, fetchAllRegistrations } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
@@ -17,6 +17,7 @@ import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +25,7 @@ function fmtPrice(n: number) {
|
||||
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
||||
// Effective unit price for an existing registration option (as returned by /api/v2/registrations).
|
||||
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
||||
// back to a live early-bird calculation for legacy rows without a snapshot.
|
||||
function optionUnitPrice(opt: RegistrationOption): number {
|
||||
@@ -103,7 +104,7 @@ export default function ManualRegistrationPage() {
|
||||
// balances can be computed without a per-registration fetch loop).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Registration[]>("/api/registrations", { authToken: token })
|
||||
fetchAllRegistrations(token)
|
||||
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -163,8 +164,7 @@ export default function ManualRegistrationPage() {
|
||||
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
||||
try {
|
||||
setPaySubmitting(true);
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
@@ -173,14 +173,14 @@ export default function ManualRegistrationPage() {
|
||||
registrationId: payRegistrationId,
|
||||
isDonation: false,
|
||||
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
|
||||
setPayAmount("");
|
||||
setPayPaidAtLocal("");
|
||||
// Refresh registrations so the displayed outstanding balance updates
|
||||
try {
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token);
|
||||
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -346,8 +346,7 @@ export default function ManualRegistrationPage() {
|
||||
const hasEmail = !!guest.email.trim();
|
||||
const hasPhone = !!guest.phoneNumber.trim();
|
||||
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId: selectedEventId,
|
||||
@@ -358,8 +357,8 @@ export default function ManualRegistrationPage() {
|
||||
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
|
||||
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
},
|
||||
}) as Promise<Registration & { couponError?: string }>);
|
||||
setMessage(res?.couponError
|
||||
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
|
||||
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
@@ -55,8 +56,8 @@ export default function OfflineScansPage() {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const [l, r] = await Promise.all([
|
||||
apiFetch<Lock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token }),
|
||||
apiFetch<Report[]>(`/api/scan-locks/reports?eventId=${encodeURIComponent(eventId)}${onlyOpen ? "&unreviewed=true" : ""}`, { authToken: token }),
|
||||
(apiV2Data("get", "/scan-locks", { query: { eventId: eventId }, authToken: token }) as Promise<Lock[]>),
|
||||
(apiV2Data("get", "/scan-locks/reports", { query: { eventId, ...(onlyOpen ? { unreviewed: "true" } : {}) }, authToken: token }) as Promise<Report[]>),
|
||||
]);
|
||||
setLocks(l);
|
||||
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
|
||||
@@ -68,14 +69,14 @@ export default function OfflineScansPage() {
|
||||
|
||||
const forceRelease = async (l: Lock) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
|
||||
const res = await (apiV2Data("post", "/scan-locks/{id}/force-release", { params: { id: l.id }, authToken: token, body: {} }) as Promise<{ message: string }>);
|
||||
setInfo(res.message);
|
||||
load();
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
const review = async (r: Report) => {
|
||||
try {
|
||||
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
|
||||
await apiV2Data("post", "/scan-locks/reports/{id}/review", { params: { id: r.id }, authToken: token, body: {} });
|
||||
load();
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/type
|
||||
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
|
||||
import { TodayPanel } from "@/components/dashboard/TodayPanel";
|
||||
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -91,9 +91,9 @@ export default function SupervisorDashboardPage() {
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [data, overviewData] = await Promise.all([
|
||||
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
|
||||
(apiV2Data("get", "/stats/supervisor", { authToken: token }) as Promise<DashboardStats>),
|
||||
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
|
||||
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
|
||||
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
|
||||
]);
|
||||
setScanStats(data.scanStats);
|
||||
setPaymentStats(data.paymentStats);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchAllPayments, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchAllUsers, fetchAllPayments, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||
@@ -14,6 +14,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
@@ -193,7 +194,7 @@ function PaymentsContent() {
|
||||
setLoadingList(true);
|
||||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||
const [recent, today, donations] = await Promise.all([
|
||||
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
|
||||
(apiV2Data("get", "/payments", { query: { page: "1", limit: "40" }, authToken: token }) as Promise<{ data: Payment[] }>),
|
||||
fetchAllPayments(token, { from: start.toISOString() }),
|
||||
fetchAllPayments(token, { donations: "1" }),
|
||||
]);
|
||||
@@ -209,14 +210,14 @@ function PaymentsContent() {
|
||||
|
||||
useEffect(() => { loadPayments(); }, [loadPayments]);
|
||||
|
||||
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
|
||||
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/v2/registrations
|
||||
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
|
||||
// the chosen person's own registrations (including paid ones) when needed.
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token, { unpaid: true });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
setRegOutstanding(computeOutstanding(list));
|
||||
// Sort by createdAt desc
|
||||
@@ -301,17 +302,16 @@ function PaymentsContent() {
|
||||
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||
try {
|
||||
setLinkGenerating(true);
|
||||
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/payment-link", {
|
||||
params: { id: linkRegistrationId },
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: linkRegistrationId,
|
||||
amount: amt,
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
});
|
||||
}) as Promise<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>);
|
||||
if (res.priceUpdated) {
|
||||
setError(res.message || "Pricing has changed for this registration; please re-check the amount.");
|
||||
return;
|
||||
@@ -342,10 +342,10 @@ function PaymentsContent() {
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
setLinkSending(channel);
|
||||
await apiFetch("/api/payments/checkout/send", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/registrations/{id}/payment-link/send", {
|
||||
params: { id: linkResult.registrationId },
|
||||
authToken: token,
|
||||
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
|
||||
body: { redirectUrl: linkResult.redirectUrl, channel },
|
||||
});
|
||||
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
|
||||
} catch (e) {
|
||||
@@ -405,8 +405,7 @@ function PaymentsContent() {
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
@@ -416,7 +415,7 @@ function PaymentsContent() {
|
||||
eventId: eventId || undefined,
|
||||
isDonation: !!isDonation,
|
||||
paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
setInfo(`Payment created (R ${amt.toFixed(2)})`);
|
||||
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
|
||||
@@ -447,7 +446,7 @@ function PaymentsContent() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setTxLoading(true);
|
||||
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/provider-transactions/unreconciled", { authToken: token }) as Promise<{ data?: ProviderTx[] } | ProviderTx[]>);
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||||
setProviderTxs(items);
|
||||
} catch {
|
||||
@@ -486,18 +485,18 @@ function PaymentsContent() {
|
||||
setReconcileForm(prev => ({ ...prev, submitting: true }));
|
||||
if (type === 'reg') {
|
||||
if (!registrationId) { setError('Please select a registration'); return; }
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
|
||||
params: { id: txId },
|
||||
authToken: token,
|
||||
body: { registrationId }
|
||||
body: { registrationId },
|
||||
});
|
||||
setInfo('Reconciled to registration');
|
||||
} else {
|
||||
if (!eventId) { setError('Please select an event'); return; }
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
|
||||
params: { id: txId },
|
||||
authToken: token,
|
||||
body: { eventId, userId: userId || undefined }
|
||||
body: { eventId, userId: userId || undefined },
|
||||
});
|
||||
setInfo('Reconciled as donation');
|
||||
}
|
||||
@@ -617,7 +616,7 @@ function PaymentsContent() {
|
||||
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
await apiV2Data("post", "/provider-transactions/{id}/ignore", { params: { id: tx.id }, authToken: token, body: {} });
|
||||
setInfo('Transaction ignored');
|
||||
await loadUnreconciled();
|
||||
} catch (e) {
|
||||
@@ -891,7 +890,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
setLoadingUser(true);
|
||||
Promise.all([
|
||||
fetchAllPayments(token, { userId }),
|
||||
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
|
||||
fetchAllRegistrations(token, { userId }),
|
||||
]).then(([p, r]) => {
|
||||
if (cancelled) return;
|
||||
setPayments(p);
|
||||
@@ -935,9 +934,8 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
if (!method) { setErr('Select a refund method'); return; }
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await apiFetch('/api/payments/refund', {
|
||||
method: 'POST',
|
||||
authToken: token!,
|
||||
await apiV2Data("post", "/payments/refunds", {
|
||||
authToken: token,
|
||||
body: {
|
||||
userId,
|
||||
amount: amt,
|
||||
@@ -945,7 +943,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
paymentId: target === 'payment' ? paymentId : undefined,
|
||||
registrationId: target === 'registration' ? registrationId : undefined,
|
||||
reason: reason || undefined
|
||||
}
|
||||
},
|
||||
});
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||
await onDone();
|
||||
@@ -1083,10 +1081,9 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
if (!ok) return;
|
||||
try {
|
||||
setUnassigningId(leg.id);
|
||||
await apiFetch('/api/payments/unassign-donation', {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/payments/donations/unassign", {
|
||||
authToken: token,
|
||||
body: { legId: leg.id }
|
||||
body: { legId: leg.id },
|
||||
});
|
||||
await onDone();
|
||||
} catch (e) {
|
||||
@@ -1266,8 +1263,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; }
|
||||
try {
|
||||
setAssigning(true);
|
||||
await apiFetch('/api/payments/assign-donation', {
|
||||
method: 'PUT',
|
||||
await apiV2Data("post", "/payments/donations/assign", {
|
||||
authToken: token,
|
||||
body: { paymentId, registrationId, amount: amountNum }
|
||||
});
|
||||
|
||||
@@ -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, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
@@ -13,6 +13,7 @@ import { entryLineName } from "@/lib/waitlist";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -72,7 +73,7 @@ export default function WaitlistPage() {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
setEntries(await (apiV2Data("get", "/events/{id}/waitlist", { params: { id: eventId }, authToken: token }) as Promise<Entry[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the waitlist");
|
||||
} finally {
|
||||
@@ -81,10 +82,11 @@ export default function WaitlistPage() {
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: { direction: "up" | "down" }) => {
|
||||
const act = async (id: string, action: "offer" | "move" | "remove", body: { direction?: "up" | "down" } = {}) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
const path = action === "offer" ? "/waitlist/{id}/offer" : action === "move" ? "/waitlist/{id}/move" : "/waitlist/{id}/remove";
|
||||
const res = await (apiV2Data("post", path, { params: { id }, authToken: token, body }) as Promise<{ message?: string }>);
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -164,15 +166,15 @@ export default function WaitlistPage() {
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{e.status === "waiting" && (
|
||||
<>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
</>
|
||||
)}
|
||||
{(e.status === "waiting" || e.status === "expired") && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "offer")} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
)}
|
||||
{active && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "remove")} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
@@ -13,6 +13,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -248,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++;
|
||||
@@ -426,7 +428,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
@@ -461,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));
|
||||
@@ -481,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); }
|
||||
@@ -500,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"); }
|
||||
@@ -553,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]);
|
||||
@@ -568,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();
|
||||
@@ -578,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"); }
|
||||
@@ -827,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));
|
||||
@@ -839,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"); }
|
||||
@@ -851,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,7 @@
|
||||
"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";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -22,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) {
|
||||
@@ -41,8 +41,7 @@ export default function DonatePage() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/donations/checkout", {
|
||||
body: {
|
||||
eventId,
|
||||
amount: amt,
|
||||
@@ -51,7 +50,7 @@ export default function DonatePage() {
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ redirectUrl: string }>);
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -35,13 +36,13 @@ function FormsContent() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
const draft = await (apiV2Data("get", "/registrations/{id}/form-draft", { params: { id: registrationId }, authToken: token }) as Promise<{ data?: Record<number, Record<string, string>> } | null>);
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -99,14 +100,14 @@ function FormsContent() {
|
||||
setInfo('No remaining attendee forms to submit.');
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { responses: payload }
|
||||
body: { responses: payload },
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
setFormsData({});
|
||||
// On success, redirect to the user dashbaord
|
||||
@@ -120,10 +121,10 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
await apiV2Data("put", "/registrations/{id}/form-draft", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { data: formsData }
|
||||
body: { data: formsData },
|
||||
});
|
||||
setInfo('Draft saved. You can return later to finish.');
|
||||
} 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}`;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { createFullPaymentCheckout, fetchEvent } from "@/lib/api";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
@@ -124,8 +124,8 @@ export default function UserDashboardPage() {
|
||||
// waterfalling — this was previously fetched only after registrations + all
|
||||
// their payments had already resolved.
|
||||
const [myRegs, myTicks] = await Promise.all([
|
||||
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
(apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>),
|
||||
(apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>),
|
||||
]);
|
||||
setRegistrations(myRegs);
|
||||
setTickets(myTicks);
|
||||
@@ -143,7 +143,7 @@ export default function UserDashboardPage() {
|
||||
await Promise.all(
|
||||
myRegs.map(async (r) => {
|
||||
try {
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: r.id }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
@@ -189,7 +189,7 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
// Load registration to get number of submitted responses
|
||||
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const regFull = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: r.id }, authToken: token }) as Promise<Registration>);
|
||||
const submittedCount = (regFull?.formResponses || []).length;
|
||||
// Required count equals the number of main tickets purchased
|
||||
const requiredCount = (r.registrationOptions || [])
|
||||
@@ -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) {
|
||||
@@ -273,11 +271,11 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/invoice", {
|
||||
params: { id: registrationId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
@@ -295,11 +293,11 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
|
||||
params: { id: paymentId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
@@ -332,8 +330,8 @@ export default function UserDashboardPage() {
|
||||
// Refresh this registration's billing so the displayed outstanding reflects the new price
|
||||
try {
|
||||
const now = new Date();
|
||||
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: registrationId }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
@@ -409,20 +407,20 @@ export default function UserDashboardPage() {
|
||||
setCodeBusy(true);
|
||||
setCodeError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
|
||||
method: 'POST',
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/coupon", {
|
||||
params: { id: activeRegId },
|
||||
authToken: token,
|
||||
body: { code: codeInput.trim() },
|
||||
});
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
}) as Promise<{ message?: string }>);
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
const updated = myRegs.find(r => r.id === activeRegId);
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeRegId }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
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("");
|
||||
@@ -440,9 +438,9 @@ export default function UserDashboardPage() {
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
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 {}
|
||||
};
|
||||
@@ -456,7 +454,7 @@ export default function UserDashboardPage() {
|
||||
setCancelLoading(true);
|
||||
try {
|
||||
await apiV2("post", "/registrations/{id}/cancel", { params: { id: activeRegId }, authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
setActiveRegId(null);
|
||||
setShowCancelConfirm(false);
|
||||
@@ -593,16 +591,16 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(activeReg.id)}/options`, {
|
||||
method: 'PUT',
|
||||
await apiV2Data("put", "/registrations/{id}/items", {
|
||||
params: { id: activeReg.id },
|
||||
authToken: token,
|
||||
body: { options: payloadOptions },
|
||||
});
|
||||
// Reload registrations to reflect changes
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
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
|
||||
@@ -614,7 +612,7 @@ export default function UserDashboardPage() {
|
||||
setBilling(totals);
|
||||
// Payments will be refreshed lazily or on next open; fetch for this one immediately
|
||||
try {
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeReg.id }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
|
||||
setBilling(prev => ({
|
||||
@@ -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"); }
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { formatDateTime } from "@/lib/date";
|
||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||
@@ -10,6 +9,7 @@ import { Receipt } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
interface PaymentItem {
|
||||
id: string;
|
||||
@@ -52,11 +52,11 @@ export default function UserPaymentsPage() {
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
|
||||
params: { id: paymentId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
@@ -77,7 +77,7 @@ export default function UserPaymentsPage() {
|
||||
if (endDate) qs.set("endDate", endDate);
|
||||
if (method) qs.set("method", method);
|
||||
if (kind) qs.set("kind", kind);
|
||||
return `/api/payments/mypayments?${qs.toString()}`;
|
||||
return Object.fromEntries(qs);
|
||||
}, [startDate, endDate, method, kind]);
|
||||
|
||||
const loadPayments = useCallback(async (p = 1) => {
|
||||
@@ -85,7 +85,7 @@ export default function UserPaymentsPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<Paginated<PaymentItem>>(buildQuery(p), { authToken: token });
|
||||
const res = await (apiV2Data("get", "/me/payments", { query: buildQuery(p), authToken: token }) as Promise<Paginated<PaymentItem>>);
|
||||
setPayments(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
|
||||
@@ -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'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'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'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>}
|
||||
|
||||
@@ -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,11 +3,11 @@ 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 { MessageSquareHeart, CheckCircle2 } from "lucide-react";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
|
||||
type FeedbackInfo = {
|
||||
@@ -34,7 +34,7 @@ export default function FeedbackPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<FeedbackInfo>(`/api/feedback/public/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/feedback-forms/{token}", { params: { token: token } }) as Promise<FeedbackInfo>)
|
||||
.then(setInfo)
|
||||
.catch(e => setLoadError(e?.message || "This feedback link isn't valid."));
|
||||
}, [token]);
|
||||
@@ -45,7 +45,7 @@ export default function FeedbackPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
|
||||
await apiV2Data("post", "/feedback-forms/{token}", { params: { token: token }, body: { answers } });
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't send your feedback");
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -40,13 +41,13 @@ function FormsContent() {
|
||||
setInfo(null);
|
||||
try {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft to prefill fields
|
||||
try {
|
||||
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
const draft = await (apiV2Data("get", "/registrations/{id}/form-draft", { params: { id: registrationId }, authToken: token }) as Promise<{ data?: Record<string, Record<string, string>> } | null>);
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -124,10 +125,10 @@ function FormsContent() {
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
authToken: token || undefined,
|
||||
body: { responses: payload }
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { responses: payload },
|
||||
});
|
||||
// On success, redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
@@ -141,10 +142,10 @@ function FormsContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
authToken: token || undefined,
|
||||
body: { data: formsData }
|
||||
await apiV2Data("put", "/registrations/{id}/form-draft", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { data: formsData },
|
||||
});
|
||||
setInfo('Draft saved. You can come back and finish later.');
|
||||
if (!token || !registrationId) return;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,8 +3,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ProviderForm = { action: string; fields: Record<string, string> };
|
||||
type PayPage = {
|
||||
@@ -40,7 +41,7 @@ export default function PayPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
apiFetch<PayPage>(`/api/payments/pay/${encodeURIComponent(id)}`)
|
||||
(apiV2Data("get", "/payment-links/{id}", { params: { id: id } }) as Promise<PayPage>)
|
||||
.then(p => {
|
||||
setPage(p);
|
||||
if (p.status === "open") go(p);
|
||||
@@ -56,7 +57,7 @@ export default function PayPage() {
|
||||
setBusy(provider);
|
||||
setError(null);
|
||||
try {
|
||||
go(await apiFetch<{ redirectUrl: string | null; form: ProviderForm | null }>(`/api/payments/pay/${encodeURIComponent(id)}`, { method: "POST", body: { provider } }));
|
||||
go(await (apiV2Data("post", "/payment-links/{id}", { params: { id: id }, body: { provider } }) as Promise<{ redirectUrl: string | null; form: ProviderForm | null }>));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't start the payment. Please try again.");
|
||||
setBusy(null);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
@@ -394,7 +393,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
useEffect(() => {
|
||||
const t = new URLSearchParams(window.location.search).get("waitlist");
|
||||
if (!t) return;
|
||||
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
|
||||
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: t } }) as Promise<WaitlistOffer & { status: string }>)
|
||||
.then(res => {
|
||||
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
|
||||
setWaitlistToken(t);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CheckCircle2 } from "lucide-react";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -40,7 +41,7 @@ function RegistrationSuccessContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
// Try to fetch registration and event form
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
const r = token ? await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>) : null;
|
||||
if (r) {
|
||||
setReg(r);
|
||||
const ev = await (await import('@/lib/api')).fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(r.eventId);
|
||||
@@ -74,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);
|
||||
@@ -95,7 +96,7 @@ function RegistrationSuccessContent() {
|
||||
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
|
||||
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
|
||||
try {
|
||||
const r = await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
if (r) setReg(r);
|
||||
} catch {}
|
||||
return;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,6 +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, apiV2 } from "@/lib/apiV2";
|
||||
import { toAuthResponse } from "@/contexts/AuthContext";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -336,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("");
|
||||
@@ -393,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 },
|
||||
});
|
||||
@@ -478,11 +480,10 @@ export default function SelfServicePage() {
|
||||
payload.attendees = kioskNames;
|
||||
payload.ticketDelivery = delivery;
|
||||
}
|
||||
const result = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
const result = await (apiV2Data("post", "/registrations/manual", {
|
||||
body: payload as never,
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
}) as Promise<Registration>);
|
||||
|
||||
setCurrentRegistrationId(result?.id || null);
|
||||
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
|
||||
@@ -539,8 +540,8 @@ export default function SelfServicePage() {
|
||||
try {
|
||||
const responses = collectedAnswers.map((answers) => ({ answers }));
|
||||
|
||||
await apiFetch(`/api/registrations/${currentRegistrationId}/forms/responses`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: currentRegistrationId },
|
||||
body: { responses },
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
@@ -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.");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,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,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,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.");
|
||||
|
||||
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Claim = {
|
||||
status: "offered" | "expired";
|
||||
@@ -30,7 +31,7 @@ export default function WaitlistClaimPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: token } }) as Promise<Claim>)
|
||||
.then(setClaim)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
|
||||
}, [token]);
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Wallet } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { PayFastSettings } from "@/components/admin/PayFastSettings";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PaymentsTab({ active }: { active: boolean }) {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ providers: ProviderSetting[]; minimumAmount: number }>("/api/payments/providers/settings", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/payments/providers/settings", { authToken: token }) as Promise<{ providers: ProviderSetting[]; minimumAmount: number }>);
|
||||
setProviders(res.providers);
|
||||
setMinimum(res.minimumAmount);
|
||||
} catch (e) {
|
||||
@@ -40,9 +40,10 @@ export function PaymentsTab({ active }: { active: boolean }) {
|
||||
setProviders(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string; providers: ProviderSetting[] }>("/api/payments/providers/settings", {
|
||||
method: "PUT", authToken: token, body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
});
|
||||
const res = await (apiV2Data("put", "/payments/providers/settings", {
|
||||
authToken: token,
|
||||
body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
}) as Promise<{ message: string; providers: ProviderSetting[] }>);
|
||||
setProviders(res.providers);
|
||||
setMessage({ type: "ok", text: res.message });
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
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";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Action = "" | "message" | "markPaid" | "moveOption" | "allocate" | "cancel" | "export";
|
||||
type Result = { id: string; ok: boolean; skipped?: boolean; message: string };
|
||||
@@ -55,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]);
|
||||
@@ -102,7 +103,7 @@ export function RegistrationBulkBar({ selected, events, token, onClear, onDone }
|
||||
: {};
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<BulkResponse>("/api/registrations/bulk", { method: "POST", authToken: token, body: { action, registrationIds: selected.map(r => r.id), ...params } });
|
||||
const res = await (apiV2Data("post", "/registrations/bulk", { authToken: token, body: { action: action as Exclude<typeof action, "">, registrationIds: selected.map(r => r.id), ...params } }) as Promise<BulkResponse>);
|
||||
setResult(res);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user