Compare commits

..
Author SHA1 Message Date
joshua 3693d5f2c8 Merge branch 'fix/door-tickets-corrections' 2026-11-02 12:24:00 +02:00
joshuaandClaude Opus 5.5 62b10c3db5 Fix: door and manual registrations get their tickets; admin Corrections (1.52.0)
On events with a required attendee form, tickets waited for the forms, but
At the door and Manual registration never asked for them: payment was
recorded and nobody could be checked in.

- The form pops up at the door and in Manual registration; "Fill in later"
  (any staff) issues the tickets once paid and sends the payer a link
  (Registration.formWaivedAt, POST /api/v2/registrations/{id}/form-later).
- One form per person on picked-session bookings; a required form with no
  questions no longer holds tickets back; every caller asks the ticket
  generator (formsOutstanding).
- Kiosk asks for the forms the server says are missing; headings show as text.
- Admin Corrections page and /api/v2/corrections: issue missing tickets,
  pay later (ticketsReleasedAt), make it free (complimentaryDiscount),
  check in or undo after the event.
- Migration 20261102120000_corrections; unit and e2e tests.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 16:40:27 +02:00
joshuaandClaude Opus 5.5 a7231aad0c WIP 1.51.12: tickets, scan locks, attendance, allocations on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:52:31 +02:00
joshua 254a5daebd Merge branch 'feat/api-v2-money' 2026-10-29 13:47:49 +02:00
157 changed files with 41032 additions and 744 deletions
+167
View File
@@ -7,6 +7,173 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.52.0] - 2026-11-02 — urgent
### Fixed
- **Paid door and manual registrations no longer end up without tickets.** On an event with a
required attendee form, tickets wait until a form is in for each person — but At the door and
Manual registration never asked for the form, so payment was recorded and nobody could be checked
in. The form now pops up when someone is registered, selected or pays at the door (and after
registering or paying in Manual registration): fill it in, or **Fill in later**, which issues the
tickets once it's paid and sends the payer a link to the form (`POST
/api/v2/registrations/{id}/form-later`, staff; `Registration.formWaivedAt`). Closing the popup
counts as Fill in later. The door's check-in panel says when a form is holding tickets back.
- **Picked-session bookings needed a form per session.** A person booked on three sessions needed
three forms before their tickets were issued; each person now needs one (`formPlaces` in
`utils/ticketUtils.js`).
- **A required form with only statements and headings held tickets back for ever** (nothing to
answer). Only a required form with a question counts now (`formNeedsAnswers`). Every place that
decided this on its own (coupon making it free, manual and online registration) now asks the
ticket generator (`formsOutstanding`).
- **Self-service kiosk:** it asked for one form per main ticket picked, so a visitor adding to an
existing registration (or booking picked sessions) could finish with the forms still short and
no tickets. It now asks for the forms the server says are missing (`formsNeeded` on `POST
/api/v2/registrations/manual`). Headings on the form showed as an empty text box; they're shown
as text now, with the statements.
- Recording a payment answers `formsNeeded` when a form still holds the tickets back.
### Added
- **Corrections** (admins, `/dashboard/admin/corrections`): every registration at an event —
past and closed ones too — with what needs putting right. Issue all missing tickets at once
(forms left for later); fill in a form; issue tickets before payment ("pay later", the balance
is still owed — `Registration.ticketsReleasedAt`); make it free (the balance is written off as a
complimentary discount, `Registration.complimentaryDiscount`, part of `discountTotal`, shown as
"Complimentary" on invoices and receipts; not on closed events); and check a ticket in or undo it
after the event, for any session (a manual check-in, no message sent). Sending the tickets is a
choice. `/api/v2/corrections/…` (admin): `GET events/{id}`, `POST events/{id}/issue-missing`,
`POST registrations/{id}/tickets`, `POST registrations/{id}/complimentary`,
`POST tickets/{id}/check-in`. Code in `src/services/corrections.ts`.
- Audit actions `attendee_form_waived`, `tickets_issued_override`, `registration_complimentary`
and `checkin_corrected`. Migration `20261102120000_corrections`.
### Upgrading
- Update before your next event with a required attendee form. After updating, open Admin →
Corrections for a past event to issue any tickets that went missing (leave "Send them" off if
the event is over).
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
(`/payments/yoco/…`, `/payments/payfast/…`).
- `v1Route` handles `res.download` and `res.sendFile`.
### Changed
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
- Ending a viewing-as session works on v2 while the session is read-only.
## [1.51.14] - 2026-10-29
### Added
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
- `defineRoute` refuses a method and path defined twice (the first would silently win).
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
### Changed
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
reports and the public event lists use v2.
## [1.51.13] - 2026-10-29
### Added
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
### Changed
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
## [1.51.12] - 2026-10-29
### Added
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
read v2's error shape.
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
404 instead of waiting.
### Changed
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
set-up use v2.
## [1.51.11] - 2026-10-29
### Added
+15 -1
View File
@@ -327,6 +327,12 @@ Feedback → Attendance**.
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Required forms and tickets** — a required form (with at least one question)
holds a registration's tickets back until a form is in for each person (a
picked-session booking counts each person once). At the door and in Manual
registration the form pops up to fill in with the attendee; **Fill in later**
(any staff) issues the tickets anyway once it's paid and sends the payer a link
to the form. The kiosk asks the visitor for exactly the forms still missing.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
@@ -492,6 +498,14 @@ logout controls. Disconnected sessions are recovered automatically.
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Corrections** (`/dashboard/admin/corrections`, admins only) — for any event,
past and closed ones too: registrations missing tickets, with forms to fill in,
or owing money. **Issue all missing tickets** at once (forms left for later),
fill in a form, **issue tickets — pay later** (balance still owed), **make it
free** (the balance written off as a complimentary discount, so income stays
what was paid; not on closed events), and **check people in (or undo) after the
event** for any session. Optionally send the tickets. Every correction is in
the audit log with the reason given.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
@@ -938,4 +952,4 @@ can't start, the same jobs run on in-process timers.
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+1 -1
View File
@@ -137,7 +137,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
+49
View File
@@ -12,6 +12,55 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.52.0 — urgent
- **Paying at the door always gives people their tickets now.** On events with a required
attendee form, people registered at the door or in Manual registration were paid but got no
tickets, so they couldn't be checked in. The form now pops up to fill in with them — or tap
**Fill in later**: the tickets are issued straight away and they're sent a link to fill in the
form afterwards.
- **New: Corrections (for admins).** Pick any event, even one that's over, to see who's missing
tickets, still has a form to fill in or owes money. Issue all missing tickets in one click, give
someone their tickets now and let them pay later, make a ticket free, or check people in after
the event.
- The self-service kiosk asks for exactly the forms still needed, and shows headings on the form
as text instead of an empty box.
- People booked on several sessions now fill in one form each, not one per session.
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
as before.
## 1.51.14
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
saved filters and report files now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.13
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
the server's connection to the app. They work just as before.
## 1.51.12
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
certificates and group allocations now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.11
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.11",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.11",
"version": "1.52.0",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.11",
"version": "1.52.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,12 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendee_form_waived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'tickets_issued_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'registration_complimentary';
ALTER TYPE "AdminAuditAction" ADD VALUE 'checkin_corrected';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "formWaivedAt" TIMESTAMP(3),
ADD COLUMN "formWaivedById" TEXT,
ADD COLUMN "ticketsReleasedAt" TIMESTAMP(3),
ADD COLUMN "ticketsReleasedById" TEXT,
ADD COLUMN "complimentaryDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
+13
View File
@@ -505,6 +505,15 @@ model Registration {
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
// Corrections (1.52.0). The attendee form can come later: tickets don't wait for it ("Fill in
// later" at the door or in Manual registrations, or an admin in Corrections).
formWaivedAt DateTime?
formWaivedById String?
// An admin issued the tickets before it was paid ("pay later"): the balance is still owed.
ticketsReleasedAt DateTime?
ticketsReleasedById String?
// A balance an admin wrote off ("make it free"); part of discountTotal (utils/coupons.js).
complimentaryDiscount Float @default(0)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -778,6 +787,10 @@ enum AdminAuditAction {
event_sessions_changed
attendance_marked
attendance_unmarked
attendee_form_waived
tickets_issued_override
registration_complimentary
checkin_corrected
certificate_issued
certificate_revoked
job_run_manual
+8 -2
View File
@@ -99,8 +99,10 @@ const globalLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
skip: (req) => RAW_BODY_PREFIXES.some(p => req.path.startsWith(p)),
});
// Incoming webhooks and links in sent messages on v2 (1.51.16): see src/v2/links.ts.
const { RAW_BODY_PREFIXES, webhooksRouter, linksRouter, legacyLinkRedirects } = require('./v2/links');
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
@@ -115,7 +117,7 @@ app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
if (RAW_BODY_PREFIXES.some(p => req.path.startsWith(p))) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
@@ -161,6 +163,7 @@ const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
app.use('/api/v2/webhooks', webhooksRouter);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
@@ -196,7 +199,10 @@ app.get('/health', async (req, res) => {
// Use routes
// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2.
app.use('/api/v2', linksRouter);
app.use('/api/v2', require('./v2').v2Router());
// Links in messages already sent: v1 addresses answer 308 with their v2 one.
app.use(legacyLinkRedirects);
app.use('/docs/v2', require('./v2').v2DocsRouter());
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
+6 -2
View File
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
@@ -238,7 +238,9 @@ const createPayment = async (req, res) => {
// If it's a registration payment, update registration status
let updatedRegistration = null;
let generatedTickets = [];
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
let formsNeeded = 0;
if (registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
@@ -284,6 +286,7 @@ const createPayment = async (req, res) => {
} catch (error) {
console.error('Error generating tickets:', error);
}
formsNeeded = await formsOutstanding(registrationId);
}
}
@@ -318,6 +321,7 @@ const createPayment = async (req, res) => {
...updatedRegistration
},
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
formsNeeded,
donationSplit: donationSplit || undefined
};
res.status(201).json(paymentWithUpdatedRegistration);
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/forgot', loginLimiter, requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
+10 -1
View File
@@ -207,6 +207,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
+345
View File
@@ -0,0 +1,345 @@
/**
* Corrections (1.52.0): putting registrations right when the normal flow didn't finish — tickets
* held back by a required attendee form, tickets needed before it's paid, a balance to write off,
* and check-ins after the event. Admins only, from Corrections; "Fill in later" (leaveFormForLater)
* is for any staff at the door and in Manual registrations.
*
* Every change is in the audit log with who did it and the reason given.
*/
import { prisma as typedPrisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
const prisma = typedPrisma as any;
const { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
formPlaces(registrationOptions: any[]): number;
formNeedsAnswers(form: any): boolean;
};
const { computeRegistrationTotalDue } = require('../utils/pricing') as { computeRegistrationTotalDue(registration: any, at: Date): number };
const { recalculateRegistrationDiscount } = require('../utils/coupons') as { recalculateRegistrationDiscount(id: string): Promise<number> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
const sessions = require('../utils/sessions') as {
activeSessions(sessions: any[]): any[];
sessionLabel(session: any, sessions: any[]): string;
lineCovers(line: any, sessionId: string): boolean;
lineSessions(line: any): string[] | null;
redeemedForSession(usages: any[], sessionId: string | null): number;
};
export type Actor = { id: string; role: string };
type Audit = { actor: Actor; ip?: string };
/** The key a single-date event's check-ins are counted under. */
export const WHOLE_EVENT = 'event';
const round2 = (n: number) => Math.round(n * 100) / 100;
const realEmail = (email: string | null | undefined) => (email && !email.endsWith('@guest.local') ? email : null);
const REGISTRATION_INCLUDE = {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: { select: { amount: true, createdAt: true } },
formResponses: { select: { id: true } },
attendees: { select: { id: true, name: true } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { name: true } },
tranches: true,
tickets: {
include: { usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, holder: { select: { name: true } } },
orderBy: { createdAt: 'asc' },
},
},
},
};
async function emailTicketsTo(registrationId: string, userId: string) {
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const res: any = { status: () => res, json: () => {} };
try {
await emailTickets({ user: { id: userId }, body: { registrationId } }, res);
} catch (e: any) {
console.error('[corrections] Failed to send tickets:', e?.message || e);
}
}
async function loadEvent(eventId: string) {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, cashupStatus: true,
sessions: { select: { id: true, label: true, startsAt: true, endsAt: true, cancelledAt: true } },
form: { include: { fields: { select: { type: true } } } },
},
});
return event || fail(404, 'Event not found');
}
/** One registration as Corrections shows it: money, forms, tickets and their check-ins. */
function correctionRow(reg: any, event: any, formRequired: boolean) {
const now = new Date();
const totalDue = round2(computeRegistrationTotalDue(reg, now));
const paid = round2((reg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0));
const active = sessions.activeSessions(event.sessions || []);
const multi = event.scheduleType === 'sessions';
let places = 0;
let ticketPlaces = 0;
const tickets: any[] = [];
for (const ro of reg.registrationOptions || []) {
places += ro.quantity || 0;
const session = ro.sessionId ? active.find((s: any) => s.id === ro.sessionId) : null;
const label = [ro.eventOption?.name, ro.variant?.name].filter(Boolean).join(' — ')
+ (session ? ` (${sessions.sessionLabel(session, event.sessions)})` : '');
for (const t of ro.tickets || []) {
ticketPlaces += t.quantity || 0;
const covered = multi ? (sessions.lineSessions(ro) ?? active.map((s: any) => s.id)).filter((id: string) => active.some((s: any) => s.id === id)) : [WHOLE_EVENT];
const checkedIn: Record<string, number> = {};
for (const key of covered) checkedIn[key] = sessions.redeemedForSession(t.usages, multi ? key : null);
tickets.push({
id: t.id,
qrCode: t.qrCode,
label,
isMainTicket: !!ro.eventOption?.isMainTicket,
name: t.attendee?.name || t.holder?.name || reg.user?.name || '',
quantity: t.quantity || 1,
sessionIds: covered,
checkedIn,
});
}
}
const ticketsDue = reg.status === 'paid' || !!reg.ticketsReleasedAt;
const formsNeeded = formRequired && !reg.formWaivedAt ? Math.max(0, formPlaces(reg.registrationOptions) - (reg.formResponses || []).length) : 0;
return {
id: reg.id,
status: reg.status,
createdAt: reg.createdAt,
user: { id: reg.user?.id, name: reg.user?.name || '', email: realEmail(reg.user?.email), phoneNumber: reg.user?.phoneNumber || null },
totalDue,
paid,
balance: round2(Math.max(0, totalDue - paid)),
places,
ticketPlaces,
missingTickets: ticketsDue && ticketPlaces < places,
formsNeeded,
formWaived: !!reg.formWaivedAt,
ticketsReleased: !!reg.ticketsReleasedAt,
complimentary: Number(reg.complimentaryDiscount || 0),
tickets,
};
}
function eventSummary(event: any) {
const active = sessions.activeSessions(event.sessions || []);
return {
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
closed: event.cashupStatus === 'closed',
formRequired: formNeedsAnswers(event.form),
sessions: event.scheduleType === 'sessions'
? active.map((s: any) => ({ id: s.id, label: sessions.sessionLabel(s, event.sessions), startsAt: s.startsAt }))
: [],
};
}
/** An event's registrations (not cancelled) with what may need putting right. */
export async function eventCorrections(eventId: string) {
const event = await loadEvent(eventId);
const formRequired = formNeedsAnswers(event.form);
const regs = await prisma.registration.findMany({
where: { eventId, status: { not: 'cancelled' } },
include: REGISTRATION_INCLUDE,
orderBy: { createdAt: 'asc' },
});
return { event: eventSummary(event), registrations: regs.map((r: any) => correctionRow(r, event, formRequired)) };
}
async function registrationRow(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!reg) return fail(404, 'Registration not found');
const event = await loadEvent(reg.eventId);
return correctionRow(reg, event, formNeedsAnswers(event.form));
}
async function activeRegistration(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: true } } } });
if (!reg) return fail(404, 'Registration not found');
if (reg.status === 'cancelled') return fail(400, 'This registration is cancelled.');
return reg;
}
async function waiveForm(reg: any, actorId: string) {
if (reg.formWaivedAt || (await formsOutstanding(reg)) === 0) return false;
await prisma.registration.update({ where: { id: reg.id }, data: { formWaivedAt: new Date(), formWaivedById: actorId, updatedAt: new Date() } });
return true;
}
/**
* "Fill in later" at the door / in Manual registrations: the tickets stop waiting for the
* attendee form, and the payer is sent a link to fill it in (`notify`).
*/
export async function leaveFormForLater(registrationId: string, { actor, ip }: Audit, { notify = true }: { notify?: boolean } = {}) {
const reg = await activeRegistration(registrationId);
const waived = await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
if (created.length > 0) await emailTicketsTo(reg.id, reg.userId);
let sentTo: string[] = [];
if (waived) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'door' }, ip });
if (notify) {
const { sendFormReminderMessage } = require('../utils/notifications') as { sendFormReminderMessage(id: string): Promise<string[]> };
sentTo = await sendFormReminderMessage(reg.id).catch(() => []);
}
}
return { ticketsIssued: created.length, sentTo };
}
type IssueOptions = { skipForm?: boolean; payLater?: boolean; send?: boolean; reason?: string | null };
/**
* Admin: issue a registration's tickets now — leaving the attendee form for later (`skipForm`)
* and/or before it's paid (`payLater`, the balance is still owed).
*/
export async function issueTickets(registrationId: string, { actor, ip }: Audit, opts: IssueOptions) {
const reg = await activeRegistration(registrationId);
if (reg.status !== 'paid' && !reg.ticketsReleasedAt && !opts.payLater) {
return fail(400, 'This registration isn\'t paid yet. Choose "pay later" or "make it free" to issue its tickets.');
}
const outstanding = opts.skipForm ? 0 : await formsOutstanding(reg);
if (outstanding > 0) {
return fail(400, `${outstanding} attendee form${outstanding === 1 ? ' is' : 's are'} still to fill in. Fill ${outstanding === 1 ? 'it' : 'them'} in, or choose to leave the form for later.`);
}
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
const released = !!opts.payLater && reg.status !== 'paid' && !reg.ticketsReleasedAt;
if (released) {
await prisma.registration.update({ where: { id: reg.id }, data: { ticketsReleasedAt: new Date(), ticketsReleasedById: actor.id, updatedAt: new Date() } });
}
const created = await generateTicketsForRegistration(reg.id);
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, formLeftForLater: waived, payLater: released, ticketsCreated: created.length, reason: opts.reason || null },
ip,
});
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
return { ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: write off what's still owed ("make it free") — it comes off as a complimentary
* discount, so income stays what was really paid — and issue the tickets.
*/
export async function makeComplimentary(registrationId: string, { actor, ip }: Audit, opts: Omit<IssueOptions, 'payLater'>) {
const reg = await activeRegistration(registrationId);
try {
await assertEventOpen(reg.eventId);
} catch (e: any) {
return fail(400, e?.message || 'This event is closed.');
}
const full = await prisma.registration.findUnique({
where: { id: reg.id },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(full, new Date());
const paid = (full.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const balance = round2(due - paid);
if (!(balance > 0.004)) return fail(400, 'Nothing is owed on this registration.');
await prisma.registration.update({ where: { id: reg.id }, data: { complimentaryDiscount: round2(Number(full.complimentaryDiscount || 0) + balance), updatedAt: new Date() } });
await recalculateRegistrationDiscount(reg.id);
await prisma.registration.update({ where: { id: reg.id }, data: { status: 'paid', updatedAt: new Date() } });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'registration_complimentary', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, amount: balance, reason: opts.reason || null }, ip,
});
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
const created = await generateTicketsForRegistration(reg.id);
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
return { amount: balance, ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: every paid registration (or one released unpaid) at an event that's missing tickets
* gets them — forms still outstanding are left for later.
*/
export async function issueMissingTickets(eventId: string, { actor, ip }: Audit, { send = false }: { send?: boolean } = {}) {
const { registrations } = await eventCorrections(eventId);
const missing = registrations.filter((r: any) => r.missingTickets);
let tickets = 0;
let fixed = 0;
const failed: { id: string; name: string; message: string }[] = [];
for (const row of missing) {
try {
const reg = await activeRegistration(row.id);
await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
tickets += created.length;
if (created.length > 0) fixed += 1;
if (send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
} catch (e: any) {
failed.push({ id: row.id, name: row.user.name, message: e?.message || 'Failed' });
}
}
if (missing.length > 0) {
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Event', targetId: eventId,
metadata: { registrations: fixed, ticketsCreated: tickets, formLeftForLater: true, sent: send, failed: failed.length }, ip,
});
}
return { registrations: fixed, tickets, failed };
}
type CheckInOptions = { sessionId?: string | null; people?: number | null; present?: boolean; reason?: string | null };
/**
* Admin: check a ticket in (or undo it) whatever the time — after the event or session has
* ended, outside the check-in window, or once the event is closed. Recorded as a manual
* check-in; no check-in message is sent.
*/
export async function correctCheckIn(ticketId: string, { actor, ip }: Audit, opts: CheckInOptions) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { usages: true, event: { select: { id: true, scheduleType: true } }, registrationOption: { select: { sessionId: true, sessionIds: true, registration: { select: { status: true } } } } },
});
if (!ticket) return fail(404, 'Ticket not found');
if (ticket.registrationOption?.registration?.status === 'cancelled') return fail(400, 'This registration is cancelled.');
const multi = ticket.event.scheduleType === 'sessions';
let sessionId: string | null = null;
if (multi) {
const session = opts.sessionId ? await prisma.eventSession.findUnique({ where: { id: opts.sessionId } }) : null;
if (!session || session.eventId !== ticket.eventId) return fail(400, 'Choose one of this event\'s sessions.');
if (session.cancelledAt) return fail(400, 'That session was cancelled.');
if (ticket.registrationOption && !sessions.lineCovers(ticket.registrationOption, session.id)) return fail(400, 'That ticket isn\'t booked for this session.');
sessionId = session.id;
}
const forSession = ticket.usages.filter((u: any) => (u.sessionId || null) === sessionId);
const redeemed = sessions.redeemedForSession(ticket.usages, sessionId);
const quantity = ticket.quantity || 1;
let changed = 0;
if (opts.present === false) {
if (forSession.length === 0) return fail(400, 'This ticket isn\'t checked in.');
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map((u: any) => u.id) } } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
changed = redeemed;
} else {
const left = quantity - redeemed;
if (left <= 0) return fail(400, 'Everyone on this ticket is already checked in.');
const people = Math.min(Math.max(1, Math.trunc(Number(opts.people)) || left), left);
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: people, sessionId, manual: true } });
if (!multi && redeemed + people >= quantity) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
changed = people;
}
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'checkin_corrected', targetType: 'Ticket', targetId: ticket.id,
metadata: { eventId: ticket.eventId, sessionId, present: opts.present !== false, people: changed, reason: opts.reason || null }, ip,
});
const after = await prisma.ticketUsage.findMany({ where: { ticketId: ticket.id }, select: { quantityRedeemed: true, sessionId: true } });
return { checkedIn: sessions.redeemedForSession(after, sessionId), quantity };
}
+18 -20
View File
@@ -18,7 +18,10 @@ import {
/* eslint-disable @typescript-eslint/no-explicit-any -- moved from JavaScript; the records are Prisma's with includes */
// The queries below build their includes and data step by step, as the JavaScript did.
const prisma = typedPrisma as any;
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<any[]> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
};
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const { hashPassword } = require('../config/auth') as { hashPassword(p: string): Promise<string> };
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing') as {
@@ -111,13 +114,9 @@ async function syncStatusAfterDiscount(registrationId: string) {
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
// Waits while a required attendee form is outstanding (see utils/ticketUtils).
const created = await generateTicketsForRegistration(registrationId);
if (created.length === 0) return;
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
@@ -840,13 +839,6 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = computeRegistrationTotalDue(registration, new Date());
@@ -856,7 +848,12 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
}
} catch {}
// Generate/sync tickets for all paid registrations that have no required form
// Attendee forms still to fill in (a required form holds the tickets back until they're in,
// or until staff choose "Fill in later" — POST /registrations/:id/form-later).
const formsNeeded = await formsOutstanding(registration.id);
const hasRequiredForm = formsNeeded > 0;
// Generate/sync tickets for all paid registrations that aren't waiting for a form
if (registration.status === 'paid' && !hasRequiredForm) try {
await generateTicketsForRegistration(registration.id);
} catch {}
@@ -912,7 +909,7 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
ip: call.ip,
});
return { ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) };
return { ...attachComputedTotals(registration), formsNeeded, ...(couponError ? { couponError } : {}) };
});
// @desc Update registration options (add/remove items) with constraints
@@ -1359,11 +1356,12 @@ export const submitFormResponses = as400(async (call: Call): Promise<any> => {
created.push(fr);
}
// After submitting responses, if registration is already paid, generate and email tickets now.
// After submitting responses, if registration is already paid (or its tickets were released
// unpaid), generate and email tickets now.
let generatedTickets = [];
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
generatedTickets = await generateTicketsForRegistration(registrationId);
if (generatedTickets && generatedTickets.length > 0) {
try {
@@ -1449,7 +1447,7 @@ export const replaceFormResponses = as400(async (call: Call): Promise<any> => {
// Attempt ticket generation and email if already paid
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: { select: { id: true } } } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
const newTickets = await generateTicketsForRegistration(registrationId);
if (newTickets && newTickets.length > 0) {
try {
+5 -3
View File
@@ -8,7 +8,10 @@ import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<unknown>;
formsOutstanding(registration: string | object): Promise<number>;
};
const pricing = require('../utils/pricing') as {
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
@@ -410,8 +413,7 @@ async function create(input: CreateRegistrationInput, actor: Actor) {
if (registration.status !== 'paid') {
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
}
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
if (!(form && form.isRequired)) {
if ((await formsOutstanding(registrationId)) === 0) {
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registrationId);
+3 -2
View File
@@ -256,7 +256,8 @@ async function registrationBundles(registration, db = prisma) {
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
* coupon's amount (capped the same way): used when only prices changed, e.g. an
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
* early-bird tier expiring. A balance an admin wrote off (`complimentaryDiscount`, 1.52.0)
* comes off last, as a fixed amount. Updates bundleDiscount and discountTotal and returns
* discountTotal. Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
@@ -280,7 +281,7 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma, { ke
}
total += amount;
}
total = round2(total);
total = round2(total + Number(registration.complimentaryDiscount || 0));
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
}
+2 -1
View File
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log', '/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
+40 -1
View File
@@ -34,7 +34,7 @@ function getBackendUrl() {
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
}
function getOrg() {
@@ -1512,10 +1512,49 @@ async function sendAttendeeFormLinkMessage(attendee) {
return sent;
}
// "Fill in later" (1.52.0): the attendee form was left for later at the door, so the payer is
// sent a link to the forms still to do — by email and/or WhatsApp as they prefer.
async function sendFormReminderMessage(registrationId) {
const org = getOrg();
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: { select: { title: true } }, user: true } });
if (!reg || !reg.user) return [];
const { shouldEmail, waText, canWhatsApp } = require('./notify');
const eventTitle = reg.event?.title || 'the event';
const link = `${org.url}/forms?registrationId=${encodeURIComponent(reg.id)}`;
const sent = [];
if (reg.user.email && !reg.user.email.endsWith('@guest.local') && shouldEmail(reg.user)) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your attendee form</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thanks for registering for <strong>${eventTitle}</strong>. We still need the attendee form — it only takes a minute.</p>
${ctaButton('Fill in the form', link)}
${fallbackLink(link)}
`, { preheader: `Your attendee form for ${eventTitle}` });
try {
await sendMail({ to: reg.user.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${reg.user.name},
Thanks for registering for ${eventTitle}. We still need the attendee form:
${link}
${org.name}` });
sent.push('email');
} catch (e) {
console.warn('[form reminder] email failed:', e?.message || e);
}
}
if (canWhatsApp(reg.user)) {
await waText(reg.user, `Hi ${reg.user.name}, thanks for registering for *${eventTitle}*. We still need your attendee form: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
module.exports = {
getEventNotifyEmails,
buildEventNotifyRecipientList,
sendAttendeeFormLinkMessage,
sendFormReminderMessage,
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
+1 -1
View File
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
return base ? `${base}/api/v2/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
+10 -4
View File
@@ -96,10 +96,10 @@ function buildLineItems(registrationOptions) {
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
* Registration.discountTotal less the bundle part, both always present.
* Registration.discountTotal less the bundle and complimentary parts, all always present.
*/
function couponDiscountLine(registration) {
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0) - Number(registration?.complimentaryDiscount || 0)) * 100) / 100;
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
@@ -118,9 +118,15 @@ function bundleDiscountLine(registration) {
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
}
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
/** A balance written off by an admin (Corrections → Make it free, 1.52.0). */
function complimentaryLine(registration) {
const amount = Number(registration?.complimentaryDiscount || 0);
return amount > 0.001 ? { amount, label: 'Complimentary' } : null;
}
/** Bundle saving, coupon discount, then anything made complimentary, as display lines ({ label, amount }). */
function discountLines(registration) {
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
return [bundleDiscountLine(registration), couponDiscountLine(registration), complimentaryLine(registration)].filter(Boolean);
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
+1 -1
View File
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
+53 -14
View File
@@ -2,8 +2,55 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/** Form fields that are only text to read (a statement, a heading). */
const DISPLAY_FIELDS = ['statement', 'paragraph'];
/** A required form with at least one question to answer — the only kind tickets wait for. */
const formNeedsAnswers = (form) => !!(form && form.isRequired && (form.fields || []).some(f => !DISPLAY_FIELDS.includes(f.type)));
/**
* Generate tickets for a registration when payment status is "paid".
* How many attendee forms a registration's places need: one per main-ticket place. A picked-
* session booking has a line per session for the same people, so it counts once (its biggest
* line), not once per session.
*/
function formPlaces(registrationOptions) {
let total = 0;
const picked = new Map();
for (const ro of registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
const qty = ro.quantity || 0;
if (!ro.sessionId) { total += qty; continue; }
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
picked.set(key, Math.max(picked.get(key) || 0, qty));
}
for (const qty of picked.values()) total += qty;
return total;
}
/**
* The attendee forms a registration still needs before its tickets can be issued: 0 when the
* event's form isn't required (or has no questions), when the form was left for later
* (`formWaivedAt`), or when enough are in. `registration` is an id or a record with
* registrationOptions[].eventOption.
*/
async function formsOutstanding(registration) {
try {
const reg = typeof registration === 'string'
? await prisma.registration.findUnique({ where: { id: registration }, include: { registrationOptions: { include: { eventOption: true } } } })
: registration;
if (!reg || reg.formWaivedAt) return 0;
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId }, include: { fields: true } });
if (!formNeedsAnswers(form)) return 0;
const submitted = await prisma.formResponse.count({ where: { registrationId: reg.id } });
return Math.max(0, formPlaces(reg.registrationOptions) - submitted);
} catch (_) {
return 0; // form models unavailable — don't block
}
}
/**
* Generate tickets for a registration when payment status is "paid" (or an admin released
* them unpaid) and no required attendee form is outstanding (see formsOutstanding).
*
* Rules:
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
@@ -34,19 +81,11 @@ const generateTicketsForRegistration = async (registrationId) => {
});
if (!registration) throw new Error('Registration not found');
if (registration.status !== 'paid') return [];
// Paid, or an admin issued the tickets before payment ("pay later", 1.52.0).
if (registration.status !== 'paid' && !(registration.ticketsReleasedAt && registration.status !== 'cancelled')) return [];
// If event has a required form, ensure sufficient responses exist
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
if (form && form.isRequired) {
const requiredCount = (registration.registrationOptions || [])
.filter(ro => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
if (responsesCount < requiredCount) return [];
}
} catch (_) { /* form models unavailable — don't block */ }
// A required attendee form comes first, unless it's been left for later.
if ((await formsOutstanding(registration)) > 0) return [];
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
@@ -133,4 +172,4 @@ const generateTicketsForRegistration = async (registrationId) => {
}
};
module.exports = { generateTicketsForRegistration };
module.exports = { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers, DISPLAY_FIELDS };
+1 -1
View File
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
+1 -1
View File
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
return base ? `${base}/api/v2/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
+188
View File
@@ -46,6 +46,194 @@ const SUCCESSORS: Successor[] = [
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
// 1.51.15: privacy and admin
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
// 1.51.14: people and messaging
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
{ method: 'POST', v1: /^\/api\/users\/reset$/, v2: () => '/api/v2/auth/password/reset' },
{ method: 'POST', v1: /^\/api\/users\/activate$/, v2: () => '/api/v2/auth/activate' },
{ method: 'POST', v1: /^\/api\/users\/revoke-sessions$/, v2: () => '/api/v2/me/sessions/revoke' },
{ method: 'POST', v1: /^\/api\/users\/close-account$/, v2: () => '/api/v2/me/close' },
{ method: 'GET', v1: /^\/api\/users\/activity$/, v2: () => '/api/v2/me/activity' },
{ method: 'POST', v1: /^\/api\/users\/verify-password$/, v2: () => '/api/v2/me/verify-password' },
{ method: 'POST', v1: /^\/api\/users\/2fa\/(enable|confirm|disable)$/, v2: m => `/api/v2/me/two-factor/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/check-exists$/, v2: () => '/api/v2/users/exists' },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/revoke-sessions$/, v2: m => `/api/v2/users/${m[1]}/sessions/revoke` },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/anonymize$/, v2: m => `/api/v2/users/${m[1]}/anonymize` },
{ method: 'GET', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/users' },
// (/api/users/profile is /api/v2/me — see 1.51.2 below.)
{ method: 'GET', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/users\/([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/admin\/all$/, v2: () => '/api/v2/admin/events' },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees\/schedule$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees/schedule` },
{ method: 'POST', v1: /^\/api\/broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/email/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp-broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scheduled-emails\/?$/, v2: () => '/api/v2/scheduled-messages' },
{ method: 'PATCH', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'POST', v1: /^\/api\/automations\/schedule$/, v2: () => '/api/v2/automations/schedule' },
{ method: 'GET', v1: /^\/api\/whatsapp\/(config|status|qr)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/config$/, v2: () => '/api/v2/whatsapp/config' },
{ method: 'POST', v1: /^\/api\/whatsapp\/create-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/delete-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/(request-code|logout|start|restart)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'POST', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'GET', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'POST', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'PUT', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'GET', v1: /^\/api\/dashboard\/(today|search)$/, v2: m => `/api/v2/dashboard/${m[1]}` },
{ method: 'GET', v1: /^\/api\/stats\/(staff|supervisor|admin|overview)$/, v2: m => `/api/v2/stats/${m[1]}` },
{ method: 'POST', v1: /^\/api\/analytics\/track$/, v2: () => '/api/v2/analytics/track' },
{ method: 'GET', v1: /^\/api\/analytics\/funnel$/, v2: () => '/api/v2/analytics/funnel' },
{ method: 'POST', v1: /^\/api\/reports\/(pdf|excel|email|whatsapp)$/, v2: m => `/api/v2/reports/${m[1]}` },
// 1.51.13: kids check-in and volunteer rosters
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'PUT', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'DELETE', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me\/(children|adults)$/, v2: m => `/api/v2/me/family/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/setup-events$/, v2: () => '/api/v2/kids/setup-events' },
{ method: 'GET', v1: /^\/api\/kids\/events$/, v2: () => '/api/v2/kids/events' },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/(settings|board|families|offline)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/kids/settings` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/(families|check-in|check-out|duty|sync)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/qr\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/qr/${m[2]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/(parents|children|adults)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/${m[3]}` },
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/children\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/children/${m[3]}` },
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/code\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/code/${m[2]}` },
{ method: 'POST', v1: /^\/api\/kids\/check-ins\/([^/]+)\/(override|page)$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/kids\/check-ins\/([^/]+)\/room$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/room` },
{ method: 'POST', v1: /^\/api\/kids\/alerts\/([^/]+)\/resolve$/, v2: m => `/api/v2/kids/alerts/${m[1]}/resolve` },
{ method: 'GET', v1: /^\/api\/volunteers\/me$/, v2: () => '/api/v2/me/volunteering' },
{ method: 'GET', v1: /^\/api\/volunteers\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'POST', v1: /^\/api\/volunteers\/roles\/([^/]+)\/sign-up$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/sign-up` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/slots\/([^/]+)\/swap$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/swap` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/swaps\/([^/]+)$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/swaps\/([^/]+)\/accept$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}/accept` },
{ method: 'POST', v1: /^\/api\/volunteers\/check-in$/, v2: () => '/api/v2/volunteering/check-in' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events$/, v2: () => '/api/v2/volunteering/events' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/hours$/, v2: () => '/api/v2/volunteering/hours' },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteers` },
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/(qr|settings)$/, v2: m => `/api/v2/events/${m[1]}/volunteers/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/volunteers/settings` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/roles$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)\/assign$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/assign` },
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)\/check-in$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/check-in` },
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/remove` },
// 1.51.12: tickets, scan locks, attendance and certificates, allocations
{ method: 'GET', v1: /^\/api\/tickets\/mytickets$/, v2: () => '/api/v2/me/tickets' },
{ method: 'POST', v1: /^\/api\/tickets\/email$/, v2: () => '/api/v2/me/tickets/email' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/recent$/, v2: () => '/api/v2/tickets/scans/recent' },
{ method: 'GET', v1: /^\/api\/tickets\/scans\/stats$/, v2: () => '/api/v2/tickets/scans/stats' },
{ method: 'POST', v1: /^\/api\/tickets\/send-to$/, v2: () => '/api/v2/tickets/send' },
{ method: 'POST', v1: /^\/api\/tickets\/generate$/, v2: () => '/api/v2/tickets/generate' },
{ method: 'PUT', v1: /^\/api\/tickets\/email-sent$/, v2: () => '/api/v2/tickets/email-sent' },
{ method: 'GET', v1: /^\/api\/tickets\/qr\/([^/]+)$/, v2: m => `/api/v2/tickets/qr/${m[1]}` },
{ method: 'GET', v1: /^\/api\/tickets\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/tickets` },
{ method: 'GET', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)\/(accept|decline)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/tickets\/?$/, v2: () => '/api/v2/tickets' },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)\/transfers$/, v2: m => `/api/v2/tickets/${m[1]}/transfers` },
{ method: 'POST', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'DELETE', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)$/, v2: m => `/api/v2/tickets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'POST', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
{ method: 'GET', v1: /^\/api\/scan-locks\/reports$/, v2: () => '/api/v2/scan-locks/reports' },
{ method: 'POST', v1: /^\/api\/scan-locks\/reports\/([^/]+)\/review$/, v2: m => `/api/v2/scan-locks/reports/${m[1]}/review` },
{ method: 'GET', v1: /^\/api\/scan-locks\/([^/]+)\/snapshot$/, v2: m => `/api/v2/scan-locks/${m[1]}/snapshot` },
{ method: 'POST', v1: /^\/api\/scan-locks\/([^/]+)\/(sync|force-release)$/, v2: m => `/api/v2/scan-locks/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/scan-locks\/([^/]+)$/, v2: m => `/api/v2/scan-locks/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/verify\/([^/]+)$/, v2: m => `/api/v2/certificates/verify/${m[1]}` },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/mine$/, v2: () => '/api/v2/me/certificates' },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/preview$/, v2: () => '/api/v2/certificates/preview' },
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/([^/]+)\/pdf$/, v2: m => `/api/v2/certificates/${m[1]}/pdf` },
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/([^/]+)\/revoke$/, v2: m => `/api/v2/certificates/${m[1]}/revoke` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/export$/, v2: m => `/api/v2/events/${m[1]}/attendance/export` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/mark$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates$/, v2: m => `/api/v2/events/${m[1]}/certificates` },
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates\/send$/, v2: m => `/api/v2/events/${m[1]}/certificates/send` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/certificates\.pdf$/, v2: m => `/api/v2/events/${m[1]}/certificates.pdf` },
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
{ method: 'GET', v1: /^\/api\/allocations\/my-groups$/, v2: () => '/api/v2/me/allocation-groups' },
{ method: 'GET', v1: /^\/api\/allocations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/allocations` },
{ method: 'POST', v1: /^\/api\/allocations\/sets$/, v2: () => '/api/v2/allocation-sets' },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
{ method: 'GET', v1: /^\/api\/allocations\/sets\/([^/]+)\/lists$/, v2: m => `/api/v2/allocation-sets/${m[1]}/lists` },
{ method: 'POST', v1: /^\/api\/allocations\/sets\/([^/]+)\/(groups|auto)$/, v2: m => `/api/v2/allocation-sets/${m[1]}/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
{ method: 'PUT', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
+5
View File
@@ -15,6 +15,11 @@ import './resources/me';
import './resources/registrations';
import './resources/registrationsManage';
import './resources/money';
import './resources/attendance';
import './resources/corrections';
import './resources/kidsVolunteers';
import './resources/peopleMessaging';
import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
+52
View File
@@ -0,0 +1,52 @@
/**
* Incoming webhooks and the links in sent messages on v2 (1.51.16). These aren't JSON API calls,
* so they're plain Express routes, mounted by app.js beside the v2 router:
*
* - `webhooksRouter` at /api/v2/webhooks: Yoco, PayFast and WAWP (WhatsApp) post here. New
* provider registrations use these URLs; the v1 paths (/api/webhooks/…, /api/whatsapp/webhook)
* are still answered by the same handlers, because providers may not follow a redirect on POST.
* - `linksRouter` at /api/v2: the one-click unsubscribe link (RFC 8058) from email headers.
* - `legacyLinkRedirects` at /api: links in messages already sent (calendar files, opening a
* one-click unsubscribe link) answer 308 with their v2 address. A mail app's one-click POST to
* the v1 address is still handled there, like a webhook.
*/
import express, { Router, type RequestHandler } from 'express';
/* eslint-disable @typescript-eslint/no-explicit-any -- the handlers are JavaScript */
const { handleYocoWebhook } = require('../controllers/webhookController') as { handleYocoWebhook: RequestHandler };
const { handleProviderWebhook } = require('../payments/webhook') as { handleProviderWebhook(id: string): RequestHandler };
const { handleWebhook: handleWhatsappWebhook } = require('../controllers/whatsappController') as { handleWebhook: RequestHandler };
const privacy = require('../controllers/privacyController') as { oneClickRedirect: RequestHandler; oneClickUnsubscribe: RequestHandler };
/** Paths whose request body is read raw (signatures are checked over the exact bytes). */
export const RAW_BODY_PREFIXES = ['/api/webhooks', '/api/v2/webhooks'];
/** WAWP sends JSON (and ?secret=…); on the webhook paths its body arrives raw. */
export const whatsappWebhook: RequestHandler[] = [(req: any, _res, next) => {
try { req.body = req.rawBody ? JSON.parse(req.rawBody) : {}; } catch { req.body = {}; }
next();
}, handleWhatsappWebhook];
export const webhooksRouter = Router();
webhooksRouter.post('/yoco', handleYocoWebhook);
webhooksRouter.post('/payfast', handleProviderWebhook('payfast'));
webhooksRouter.post('/whatsapp', ...whatsappWebhook);
export const linksRouter = Router();
linksRouter.get('/unsubscribe/:token/one-click', privacy.oneClickRedirect);
linksRouter.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** v1 links in messages already sent → their v2 address (308 keeps the method). */
const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string }[] = [
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
];
export const legacyLinkRedirects: RequestHandler = (req, res, next) => {
const [path, query] = req.originalUrl.split('?');
for (const r of REDIRECTS) {
const m = req.method === r.method ? path.match(r.v1) : null;
if (m) return res.redirect(308, r.v2(m) + (query ? `?${query}` : ''));
}
next();
};
+126
View File
@@ -0,0 +1,126 @@
/**
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
* access checks as v1.
*/
import { z } from 'zod';
import { idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
const audit = require('../../controllers/adminAuditController');
const imp = require('../../controllers/impersonationController');
const jobs = require('../../controllers/jobsController');
const settings = require('../../controllers/settingsController');
const hub = require('../../controllers/hubController');
const hooks = require('../../controllers/outboundWebhookController');
const backups = require('../../controllers/backupController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const tokenParams = z.object({ token: z.string().min(1).max(500) });
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// ─── My privacy ──────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
// ─── Privacy admin ───────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
// ─── Admin ───────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
// ─── Settings and first-run set-up ───────────────────────────────────────────
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
// ─── Hub ─────────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
v1Route({
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
});
// ─── Outbound webhooks ───────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
// ─── Backups ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
const yoco = require('../../controllers/yocoController');
const payfast = require('../../controllers/payfastController');
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
+195
View File
@@ -0,0 +1,195 @@
/**
* Tickets and attendance on v2 (1.51.12): tickets (mine, one, sending, transfers, the staff
* lists, generating), scan locks (offline scanning), the attendance register and certificates,
* and allocations (groups, rooms, teams). Each route runs the v1 handler (viaV1.ts) behind the
* same access and campus checks as v1. Scanning itself is /checkin (1.51.2).
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean; nameCampus?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
campusIndependent(reason: string): any;
};
const t = require('../../controllers/ticketController');
const locks = require('../../controllers/scanLockController');
const att = require('../../controllers/attendanceController');
const alloc = require('../../controllers/allocationController');
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const loose = z.looseObject({});
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const ticketOwner = cs.scopeEvent({ resolve: 'ticket', ownerOk: true });
const qrScope = cs.scopeEvent({ resolve: 'ticketQr', from: (req: any) => req.params.qrCode, nameCampus: true });
// ─── Tickets ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', v1: t.getUserTickets });
v1Route({
method: 'post', path: '/me/tickets/email', tag: 'Tickets', summary: 'Send me my tickets',
description: 'For a registration (`registrationId`) or chosen tickets (`ticketIds`), by email and/or WhatsApp (`channel`).',
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), v1: t.emailTickets,
});
v1Route({
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), v1: t.getRecentScans,
});
v1Route({
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), v1: t.getScanStats,
});
v1Route({
method: 'post', path: '/tickets/send', tag: 'Tickets', summary: 'Send tickets to someone',
description: 'Staff: a registration\'s tickets (or chosen ones) to an email address or phone number.',
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, v1: t.sendTicketsTo,
});
v1Route({
method: 'post', path: '/tickets/generate', tag: 'Tickets', summary: 'Issue a registration\'s tickets',
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), v1: t.generateTickets,
});
v1Route({
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, v1: t.markTicketsAsEmailSent,
});
v1Route({
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), v1: t.getTickets,
});
v1Route({
method: 'get', path: '/tickets/qr/:qrCode', tag: 'Tickets', summary: 'The ticket for a QR code',
description: 'Without checking it in (that\'s POST /checkin/{qrCode}).',
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), v1: t.getTicketByQrCode,
});
v1Route({
method: 'get', path: '/events/:id/tickets', tag: 'Tickets', summary: 'An event\'s tickets',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: t.getTicketsByEvent,
});
v1Route({
method: 'get', path: '/ticket-transfers/:token', tag: 'Tickets', summary: 'A ticket someone is giving you',
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, v1: t.getTransferOffer,
});
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, v1: t.acceptTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, v1: t.declineTransferOffer });
v1Route({
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketById,
});
v1Route({
method: 'post', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Give a ticket to someone',
description: 'Sends them a link to accept it; the ticket is theirs once they do.',
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, v1: t.transferTicketHandler,
});
v1Route({
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.cancelTransferHandler,
});
v1Route({
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketTransfers,
});
// ─── Scan locks (offline scanning) ───────────────────────────────────────────
v1Route({
method: 'get', path: '/scan-locks', tag: 'Check-in', summary: 'Devices scanning offline for an event',
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), v1: locks.listLocks,
});
v1Route({
method: 'post', path: '/scan-locks', tag: 'Check-in', summary: 'Take tickets offline on this device',
description: 'Locks the chosen tickets to this device until it syncs or releases them.',
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: locks.createLock,
});
v1Route({
method: 'get', path: '/scan-locks/reports', tag: 'Check-in', summary: 'Offline sync reports to review',
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), v1: locks.listReports,
});
v1Route({
method: 'post', path: '/scan-locks/reports/:id/review', tag: 'Check-in', summary: 'Mark a sync report reviewed',
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, v1: locks.reviewReport,
});
v1Route({
method: 'get', path: '/scan-locks/:id/snapshot', tag: 'Check-in', summary: 'The tickets a device took offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), v1: locks.getSnapshot,
});
v1Route({
method: 'post', path: '/scan-locks/:id/sync', tag: 'Check-in', summary: 'Upload scans made offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.syncLock,
});
v1Route({
method: 'post', path: '/scan-locks/:id/force-release', tag: 'Check-in', summary: 'Release a lost device\'s tickets',
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.forceRelease,
});
v1Route({
method: 'delete', path: '/scan-locks/:id', tag: 'Check-in', summary: 'Release tickets back online',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), v1: locks.releaseLock,
});
// ─── Attendance and certificates ─────────────────────────────────────────────
v1Route({
method: 'get', path: '/certificates/verify/:code', tag: 'Attendance', summary: 'Check a certificate is genuine',
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), v1: att.verifyCertificate,
});
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', v1: att.myCertificates });
v1Route({
method: 'post', path: '/certificates/preview', tag: 'Attendance', summary: 'Preview a certificate template',
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, v1: att.previewCertificate,
});
v1Route({
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
description: 'Its owner, or staff.', access: 'user', params: idParams, file: { contentType: 'application/pdf' }, v1: att.downloadCertificate,
});
v1Route({
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, v1: att.revokeCertificate,
});
v1Route({
method: 'get', path: '/events/:id/attendance', tag: 'Attendance', summary: 'An event\'s attendance register',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: att.getRegister,
});
v1Route({
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
description: '`format=pdf` or `xlsx`.', access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('format', 'sessionId'),
file: { contentType: 'application/pdf' }, map: asEvent, v1: att.exportRegister,
});
v1Route({
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.markAttendance,
});
v1Route({
method: 'post', path: '/events/:id/certificates', tag: 'Attendance', summary: 'Issue certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.issueCertificates,
});
v1Route({
method: 'post', path: '/events/:id/certificates/send', tag: 'Attendance', summary: 'Send certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.sendCertificatesHandler,
});
v1Route({
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, file: { contentType: 'application/pdf' }, map: asEvent, v1: att.downloadEventCertificates,
});
// ─── Allocations (groups, rooms, teams) ──────────────────────────────────────
v1Route({
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], v1: alloc.getMyGroups,
});
v1Route({
method: 'get', path: '/events/:id/allocations', tag: 'Allocations', summary: 'An event\'s allocation board',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: alloc.getBoard,
});
v1Route({
method: 'post', path: '/allocation-sets', tag: 'Allocations', summary: 'Start a set of groups',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: alloc.createSet,
});
const setScope = cs.scopeEvent({ resolve: 'allocationSet' });
const groupScope = cs.scopeEvent({ resolve: 'allocationGroup' });
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.updateSet });
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.deleteSet });
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), v1: alloc.getSetLists });
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, v1: alloc.createGroup });
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.assign });
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.clearAssignments });
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.autoAssign });
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, v1: alloc.updateGroup });
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, v1: alloc.deleteGroup });
+114
View File
@@ -0,0 +1,114 @@
/**
* Corrections (1.52.0): see services/corrections.ts. Admins only, except "Fill in later" for
* staff at the door and in Manual registrations.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { id, idParams } from '../schemas';
import * as corrections from '../../services/corrections';
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope middleware is JavaScript */
const campusScope = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string }): any;
};
const audit = (req: Request, user: ApiUser | null) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { actor: { id: user!.id, role: user!.role }, ip: getClientIp(req) };
};
const reason = z.string().trim().max(500).nullish().describe('Why — kept in the audit log');
const send = z.boolean().optional().describe('Send the new tickets to the attendee (off by default)');
const ticketRow = z.object({
id,
qrCode: z.string(),
label: z.string().describe('Ticket option, variant and (a picked session) the session'),
isMainTicket: z.boolean(),
name: z.string().describe('Who it\'s for'),
quantity: z.number().int(),
sessionIds: z.array(z.string()).describe(`The sessions it covers (\`${corrections.WHOLE_EVENT}\` on a single-date event)`),
checkedIn: z.record(z.string(), z.number().int()).describe('People checked in, per session'),
});
const registrationRow = z.object({
id,
status: z.string(),
createdAt: z.coerce.date(),
user: z.object({ id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable() }),
totalDue: z.number(),
paid: z.number(),
balance: z.number(),
places: z.number().int().describe('Places booked (every line)'),
ticketPlaces: z.number().int().describe('Places with a ticket'),
missingTickets: z.boolean().describe('Paid (or released unpaid) but not every place has a ticket'),
formsNeeded: z.number().int().describe('Attendee forms still holding the tickets back'),
formWaived: z.boolean().describe('The form was left for later'),
ticketsReleased: z.boolean().describe('Tickets issued before it was paid'),
complimentary: z.number().describe('Balance written off'),
tickets: z.array(ticketRow),
});
export const eventCorrections = defineRoute({
method: 'get', path: '/corrections/events/:id', tag: 'Corrections', summary: 'An event\'s registrations, with what needs putting right',
description: 'Every registration that isn\'t cancelled — closed and past events too — with its balance, forms still to fill in, tickets and check-ins.',
access: 'admin', params: idParams,
response: single(z.object({
event: z.object({
id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), closed: z.boolean(),
formRequired: z.boolean(), sessions: z.array(z.object({ id, label: z.string(), startsAt: z.coerce.date() })),
}),
registrations: z.array(registrationRow),
})),
async handler({ params }) { return { data: await corrections.eventCorrections(params.id) }; },
});
export const issueMissing = defineRoute({
method: 'post', path: '/corrections/events/:id/issue-missing', tag: 'Corrections', summary: 'Issue every missing ticket at an event',
description: 'For each paid registration (or one released unpaid) without all its tickets. Attendee forms still outstanding are left for later.',
access: 'admin', params: idParams, body: z.object({ send }),
response: single(z.object({ registrations: z.number().int(), tickets: z.number().int(), failed: z.array(z.object({ id, name: z.string(), message: z.string() })) })),
async handler({ params, body, user, req }) { return { data: await corrections.issueMissingTickets(params.id, audit(req, user), body) }; },
});
export const issueTickets = defineRoute({
method: 'post', path: '/corrections/registrations/:id/tickets', tag: 'Corrections', summary: 'Issue a registration\'s tickets now',
description: '`skipForm` leaves the attendee form for later; `payLater` issues them before it\'s paid (the balance is still owed).',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), payLater: z.boolean().optional(), send, reason }),
response: single(z.object({ ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.issueTickets(params.id, audit(req, user), body) }; },
});
export const complimentary = defineRoute({
method: 'post', path: '/corrections/registrations/:id/complimentary', tag: 'Corrections', summary: 'Make it free',
description: 'What\'s still owed is written off as a complimentary discount (income stays what was really paid), it\'s marked paid and its tickets are issued. Not on a closed event.',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), send, reason }),
response: single(z.object({ amount: z.number(), ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.makeComplimentary(params.id, audit(req, user), body) }; },
});
export const checkIn = defineRoute({
method: 'post', path: '/corrections/tickets/:id/check-in', tag: 'Corrections', summary: 'Check a ticket in (or undo it) after the fact',
description: 'Any time — after the event or session, or once the event is closed. `present: false` undoes the check-in for that session. Recorded as a manual check-in; no message is sent.',
access: 'admin', params: idParams,
body: z.object({
sessionId: id.nullish().describe('Multi-day events: the session'),
people: z.number().int().min(1).max(1000).nullish().describe('How many (default: everyone left on the ticket)'),
present: z.boolean().optional().describe('false undoes it'),
reason,
}),
response: single(z.object({ checkedIn: z.number().int(), quantity: z.number().int() })),
async handler({ params, body, user, req }) { return { data: await corrections.correctCheckIn(params.id, audit(req, user), body) }; },
});
export const formLater = defineRoute({
method: 'post', path: '/registrations/:id/form-later', tag: 'Registrations', summary: 'Fill in the attendee form later',
description: 'At the door: the tickets stop waiting for the required attendee form (issued now if it\'s paid) and the payer is sent a link to fill it in (`notify: false` to not).',
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.object({ notify: z.boolean().optional() }),
response: single(z.object({ ticketsIssued: z.number().int(), sentTo: z.array(z.string()) })),
async handler({ params, body, user, req }) { return { data: await corrections.leaveFormForLater(params.id, audit(req, user), body) }; },
});
@@ -0,0 +1,92 @@
/**
* Kids check-in and volunteer rosters on v2 (1.51.13). Each route runs the v1 handler
* (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeList: any;
};
const kids = require('../../controllers/kidsController');
const vol = require('../../controllers/volunteerController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
/** v2 `:id` is the event; v1 called it `:eventId` (other params keep their names). */
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const view = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
const manage = { campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })] };
// ─── Kids: my family ─────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/family', tag: 'Kids', summary: 'My family', description: 'Children, the adults who may collect them, and the family QR code.', access: 'user', v1: kids.getMine });
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, v1: kids.createMine });
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, v1: kids.renameMine });
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', v1: kids.leaveMine });
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, v1: kids.saveMyChild });
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, v1: kids.saveMyChild });
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, v1: kids.deleteMyChild });
v1Route({ method: 'post', path: '/me/family/adults', tag: 'Kids', summary: 'Add an adult who may collect', access: 'user', status: 201, body: loose, v1: kids.saveMyAdult });
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, v1: kids.saveMyAdult });
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, v1: kids.deleteMyAdult });
// ─── Kids: set-up and the check-in desk ──────────────────────────────────────
v1Route({ method: 'get', path: '/kids/setup-events', tag: 'Kids', summary: 'Events that can have kids check-in', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.setupEvents });
v1Route({ method: 'get', path: '/kids/events', tag: 'Kids', summary: 'Events with kids check-in', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.listEvents });
v1Route({ method: 'get', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'An event\'s kids check-in set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: kids.getSettings });
v1Route({ method: 'put', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'Set up kids check-in', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: kids.saveSettings });
v1Route({ method: 'get', path: '/events/:id/kids/board', tag: 'Kids', summary: 'Who is checked in, by room', access: 'staff', ...view, params: idParams, query: filters('sessionId', 'log'), map: asEvent, v1: kids.getBoard });
v1Route({ method: 'get', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Find a family', access: 'staff', ...view, params: idParams, query: filters('q', 'sessionId'), map: asEvent, v1: kids.searchFamilies });
v1Route({ method: 'post', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Register a family at the desk', access: 'staff', ...view, status: 201, params: idParams, body: loose, map: asEvent, v1: kids.createFamilyAtDesk });
v1Route({ method: 'get', path: '/events/:id/kids/families/qr/:token', tag: 'Kids', summary: 'The family for a QR code', access: 'staff', ...view, params: z.object({ id, token: z.string().min(1).max(200) }), map: asEvent, v1: kids.familyByQr });
const familyParams = z.object({ id, familyId: id });
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, v1: kids.getFamily });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/parents', tag: 'Kids', summary: 'Add a parent at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addParentAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/children', tag: 'Kids', summary: 'Add a child at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.saveChildAtDesk });
v1Route({ method: 'put', path: '/events/:id/kids/families/:familyId/children/:childId', tag: 'Kids', summary: 'Change a child at the desk', access: 'staff', ...view, params: z.object({ id, familyId: id, childId: id }), body: loose, map: asEvent, v1: kids.saveChildAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/adults', tag: 'Kids', summary: 'Add an adult at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addAdultAtDesk });
v1Route({ method: 'post', path: '/events/:id/kids/check-in', tag: 'Kids', summary: 'Check children in', description: 'Answers the collection code and labels to print.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkIn });
v1Route({ method: 'post', path: '/events/:id/kids/check-out', tag: 'Kids', summary: 'Check children out', description: 'With the collection code; refused for someone not allowed to collect.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkOut });
v1Route({ method: 'get', path: '/events/:id/kids/code/:code', tag: 'Kids', summary: 'Who a collection code is for', access: 'staff', ...view, params: z.object({ id, code: z.string().min(1).max(40) }), query: filters('sessionId'), map: asEvent, v1: kids.lookupCode });
v1Route({ method: 'post', path: '/events/:id/kids/duty', tag: 'Kids', summary: 'Say which room I\'m on', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.setDuty });
v1Route({ method: 'get', path: '/events/:id/kids/offline', tag: 'Kids', summary: 'Everything the desk needs offline', access: 'staff', ...view, params: idParams, query: filters('deviceId', 'sessionId'), map: asEvent, v1: kids.offlineSnapshot });
v1Route({ method: 'post', path: '/events/:id/kids/sync', tag: 'Kids', summary: 'Upload check-ins made offline', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.syncOffline });
const checkInScope = { campusScope: 'scopeEvent(kidsCheckIn)', middleware: [cs.scopeEvent({ resolve: 'kidsCheckIn' })] };
v1Route({ method: 'post', path: '/kids/check-ins/:id/override', tag: 'Kids', summary: 'Let someone not on the list collect', description: 'Logged, with the reason.', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.overrideCheckOut });
v1Route({ method: 'post', path: '/kids/check-ins/:id/page', tag: 'Kids', summary: 'Page a child\'s parent', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.pageParent });
v1Route({ method: 'put', path: '/kids/check-ins/:id/room', tag: 'Kids', summary: 'Move a child to another room', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.moveRoom });
v1Route({ method: 'post', path: '/kids/alerts/:id/resolve', tag: 'Kids', summary: 'Resolve an alert', access: 'staff', campusScope: 'scopeEvent(kidsAlert)', middleware: [cs.scopeEvent({ resolve: 'kidsAlert' })], params: idParams, body: loose, v1: kids.resolveAlert });
// ─── Volunteering: mine ──────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', v1: vol.getMine });
v1Route({ method: 'get', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Roles I can serve in at an event', access: 'user', params: idParams, map: asEvent, v1: vol.getEventRoles });
v1Route({ method: 'post', path: '/volunteer-roles/:id/sign-up', tag: 'Volunteers', summary: 'Sign up to serve', access: 'user', status: 201, params: idParams, body: loose, v1: vol.signUp });
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, v1: vol.withdraw });
v1Route({ method: 'post', path: '/volunteer-slots/:id/swap', tag: 'Volunteers', summary: 'Ask someone to take my slot', access: 'user', params: idParams, body: loose, v1: vol.requestSwap });
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, v1: vol.cancelSwap });
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, v1: vol.acceptSwap });
v1Route({ method: 'post', path: '/volunteering/check-in', tag: 'Volunteers', summary: 'Check myself in (the event\'s QR code)', access: 'user', body: loose, v1: vol.checkIn });
// ─── Volunteering: rosters ───────────────────────────────────────────────────
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), v1: vol.listEvents });
v1Route({ method: 'get', path: '/volunteering/hours', tag: 'Volunteers', summary: 'Hours served', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'eventId'), v1: vol.hours });
v1Route({ method: 'get', path: '/events/:id/volunteers', tag: 'Volunteers', summary: 'An event\'s roster', access: 'staff', ...view, params: idParams, query: filters('log'), map: asEvent, v1: vol.getRoster });
v1Route({ method: 'get', path: '/events/:id/volunteers/qr', tag: 'Volunteers', summary: 'The self check-in QR code', access: 'staff', ...view, params: idParams, map: asEvent, v1: vol.getQr });
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: vol.getSettings });
v1Route({ method: 'put', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Set up volunteering', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: vol.saveSettings });
v1Route({ method: 'post', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Add a role', access: 'supervisor', ...manage, status: 201, params: idParams, body: loose, map: asEvent, v1: vol.createRole });
const roleManage = { campusScope: 'scopeEvent(servingRole, manage)', middleware: [cs.scopeEvent({ resolve: 'servingRole', need: 'manage' })] };
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, v1: vol.updateRole });
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, v1: vol.deleteRole });
v1Route({ method: 'post', path: '/volunteer-roles/:id/assign', tag: 'Volunteers', summary: 'Put someone in a role', access: 'supervisor', campusScope: 'scopeEvent(servingRole)', middleware: [cs.scopeEvent({ resolve: 'servingRole' })], params: idParams, body: loose, v1: vol.assign });
const slotScope = { campusScope: 'scopeEvent(volunteerSlot)', middleware: [cs.scopeEvent({ resolve: 'volunteerSlot' })] };
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, v1: vol.checkInManual });
v1Route({ method: 'post', path: '/volunteer-slots/:id/remove', tag: 'Volunteers', summary: 'Take someone off a slot', access: 'supervisor', ...slotScope, params: idParams, body: loose, v1: vol.remove });
+24 -4
View File
@@ -25,20 +25,40 @@ const toMe = (u: MeRow): z.input<typeof me> => ({
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { requiresTwoFactor } = require('../../utils/twoFactor') as { requiresTwoFactor(user: object): boolean };
const privacyNotice = require('../../utils/privacyNotice') as { noticePromptPending(u: { noticeVersionAccepted: unknown }): Promise<false | 'first' | 'updated'> };
const campusScope = require('../../utils/campusScope') as { scopeFor(req: unknown): Promise<{ campusIds: string[] | null } | null> };
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
description: 'With what the app asks after sign-in (`newsPromptPending`, `noticePromptPending`), whether two-step sign-in is required for their role, and the campuses they\'re limited to (`campusScope`, null = every campus). Viewing as someone else never prompts.',
access: 'user',
response: single(me),
async handler({ user }) {
response: single(me.extend({
twoFactorRequired: z.boolean().describe('Their role must use two-step sign-in'),
newsPromptPending: z.boolean(),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]),
campusScope: z.array(id).nullable(),
})),
async handler({ user, req }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: toMe(u) };
const impersonating = !!(req as any).impersonation;
return {
data: {
...toMe(u),
twoFactorRequired: requiresTwoFactor(u),
newsPromptPending: !u.newsAskedAt && !impersonating,
noticePromptPending: impersonating ? false as const : await privacyNotice.noticePromptPending({ noticeVersionAccepted: u.noticeVersionAccepted }),
campusScope: (await campusScope.scopeFor(req))?.campusIds ?? null,
},
};
},
});
+1 -1
View File
@@ -115,7 +115,7 @@ v1Route({
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\x27s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
});
+138
View File
@@ -0,0 +1,138 @@
/**
* People and messaging on v2 (1.51.14): the rest of accounts (password reset, activation,
* two-step sign-in, sessions, closing an account), messaging attendees, email and WhatsApp
* broadcasts, scheduled messages, automations, the site banner, saved filters, the dashboards,
* stats, analytics and report files. Each route runs the v1 handler (viaV1.ts) behind the same
* access and campus checks as v1.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { loginLimiter } = require('../../middleware/authMiddleware') as { loginLimiter: any };
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; from?: (req: any) => unknown }): any;
scopeBroadcast(opts: { freeformField: string }): any;
scopeList: any;
unscopedOnly: any;
campusIndependent(reason: string): any;
};
const users = require('../../controllers/userController');
const events = require('../../controllers/eventController');
const wa = require('../../controllers/whatsappController');
const bc = require('../../controllers/broadcastController');
const wbc = require('../../controllers/whatsappBroadcastController');
const sched = require('../../controllers/scheduledEmailsController');
const auto = require('../../controllers/automationController');
const banner = require('../../controllers/bannerController');
const stats = require('../../controllers/statsController');
const analytics = require('../../controllers/analyticsController');
const reports = require('../../controllers/reportController');
// These two keep their handlers in the route file; v2 runs the v1 router with the v1 path.
const savedFilterRouter = require('../../routes/savedFilterRoutes');
const dashboardRouter = require('../../routes/dashboardRoutes');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
/** The query string of the v2 request, for a v1 router's URL. */
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, v1: users.disableTwoFactor });
v1Route({
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('email', 'phone', 'phoneNumber'), v1: users.checkUserExists,
});
v1Route({
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), v1: users.getUsers,
});
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, v1: users.anonymizeUser });
// ─── Messaging an event's people ─────────────────────────────────────────────
const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
v1Route({
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
access: 'public', query: filters('campus', 'series'), v1: events.getEvents,
});
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleWhatsappEventAttendees });
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, v1: schedule });
}
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, v1: sched.deleteScheduledEmail });
v1Route({
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), v1: auto.scheduleAutomations,
});
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', v1: wa.getQrHandler });
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
v1Route({ method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose, v1: handler });
}
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, v1: banner.setBanner });
const OWN = 'campusIndependent (a person\'s own saved filters)';
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), v1: stats.getOverviewStats });
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, v1: reports.whatsappPdf });
+4
View File
@@ -78,6 +78,10 @@ export function defineRoute<P extends Schema | undefined, Q extends Schema | und
if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`);
}
// Two definitions of one route: the first would silently win.
if (routes.some(r => r.method === def.method && r.path === def.path)) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path} is defined twice`);
}
routes.push(def as AnyRoute);
return def;
}
+22 -3
View File
@@ -9,13 +9,16 @@
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
* 1.51.10 did) can follow later without changing the v2 API.
*/
import { readFile } from 'fs/promises';
import { basename } from 'path';
import { Writable } from 'stream';
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown };
/** `url` (with its query string) is for v1 routers, which route by it. */
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; url?: string };
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
@@ -72,6 +75,21 @@ class RecordingResponse extends Writable {
this.finishWith({});
return this;
}
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
const name = typeof filename === 'string' ? filename : basename(filePath);
readFile(filePath).then(buf => {
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
this.setHeader('content-disposition', `attachment; filename="${name}"`);
this.chunks.push(buf);
this.finishWith({});
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
sendFile(filePath: string) {
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
redirect(a: number | string, b?: string) {
const url = typeof a === 'string' ? a : b!;
this.statusCode = typeof a === 'number' ? a : 302;
@@ -86,7 +104,7 @@ class RecordingResponse extends Writable {
*/
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
const fake = Object.create(req) as Request;
for (const key of ['params', 'query', 'body'] as const) {
for (const key of ['params', 'query', 'body', 'url'] as const) {
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
}
const res = new RecordingResponse();
@@ -97,7 +115,8 @@ export async function runV1(handlers: RequestHandler | RequestHandler[], req: Re
advanced = true;
if (err) return reject(err);
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
else resolve();
// Nothing answered (e.g. a v1 router with no matching route).
else reject(Object.assign(new Error('There is no such API endpoint.'), { statusCode: 404 }));
};
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
});
+83
View File
@@ -0,0 +1,83 @@
/**
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
* the same for every email (before doing any per-account work), and sign-in with an unknown email
* answers exactly like a wrong password.
*/
const mockDb = {
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
import { logIn } from '../src/services/auth';
import { ServiceError } from '../src/services/errors';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { requestPasswordReset } = require('../src/controllers/userController');
type Answer = { status: number; body: unknown };
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
return new Promise(resolve => {
let status = 200;
const res = {
statusCode: 200,
status(n: number) { status = n; this.statusCode = n; return this; },
json(b: unknown) { resolve({ status, body: b }); return this; },
};
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
});
}
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
beforeEach(() => jest.clearAllMocks());
describe('forgot password', () => {
test('answers the same, with the same status, for an email with and without an account', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const known = await call(requestPasswordReset, { email: realUser.email });
expect(unknown).toEqual(known);
expect(known.status).toBe(200);
});
test('only a real account gets a link, made after the answer is sent', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
await call(requestPasswordReset, { email: 'nobody@example.org' });
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const answered = call(requestPasswordReset, { email: realUser.email });
await answered;
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
});
});
describe('sign-in', () => {
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
test('an unknown email is refused exactly like a wrong password', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
});
test('an unknown email still does a password check, so it takes as long', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* 1.52.0: tickets no longer go missing at the door. A required attendee form only holds tickets
* back until it's filled in or left for later, picked-session bookings need one form per person
* (not per session), a form with no questions holds nothing back, and admins can issue tickets
* unpaid, make a balance free and check people in after the event (services/corrections.ts).
*/
const mockDb: any = {
registration: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn() },
registrationOption: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
eventForm: { findUnique: jest.fn() },
formResponse: { count: jest.fn() },
ticket: { create: jest.fn(), update: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
ticketUsage: { create: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn() },
eventSession: { findUnique: jest.fn() },
event: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockAudit = jest.fn();
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: (...a: unknown[]) => mockAudit(...a) }));
const mockReminder = jest.fn(async () => ['email']);
jest.mock('../src/utils/notifications', () => ({ sendFormReminderMessage: (...a: unknown[]) => (mockReminder as jest.Mock)(...a) }));
const mockEmailTickets = jest.fn(async () => undefined);
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: (...a: unknown[]) => (mockEmailTickets as jest.Mock)(...a) }));
jest.mock('../src/utils/cashupUtils', () => ({ assertEventOpen: jest.fn(async () => undefined) }));
const mockRecalc = jest.fn(async () => 0);
jest.mock('../src/utils/coupons', () => ({ recalculateRegistrationDiscount: (...a: unknown[]) => (mockRecalc as jest.Mock)(...a) }));
const mockDue = jest.fn(() => 300);
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: (...a: unknown[]) => (mockDue as jest.Mock)(...a) }));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tickets = require('../src/utils/ticketUtils');
import * as corrections from '../src/services/corrections';
const admin = { actor: { id: 'adm', role: 'admin' }, ip: '1.2.3.4' };
const main = { isMainTicket: true };
const QUESTION_FORM = { isRequired: true, fields: [{ type: 'statement' }, { type: 'text' }] };
/** A registration as generateTicketsForRegistration loads it: one line of `qty` main tickets. */
const reg = (extra: object = {}, qty = 2) => ({
id: 'r1', userId: 'u1', eventId: 'ev1', status: 'paid', formWaivedAt: null, ticketsReleasedAt: null,
registrationOptions: [{ id: 'ro1', eventOptionId: 'adult', variantId: null, sessionId: null, quantity: qty, eventOption: main, tickets: [] }],
...extra,
});
beforeEach(() => {
jest.clearAllMocks();
mockDb.eventForm.findUnique.mockResolvedValue(null);
mockDb.formResponse.count.mockResolvedValue(0);
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'ro1', quantity: 2, tickets: [], attendees: [] }]);
mockDb.ticket.create.mockImplementation(async ({ data }: any) => data);
});
describe('which forms hold tickets back', () => {
test('a picked-session booking counts its people once, not once per session', () => {
const lines = [
{ eventOptionId: 'day', variantId: null, sessionId: 's1', quantity: 2, eventOption: main },
{ eventOptionId: 'day', variantId: null, sessionId: 's2', quantity: 2, eventOption: main },
{ eventOptionId: 'day', variantId: null, sessionId: 's3', quantity: 1, eventOption: main },
{ eventOptionId: 'adult', variantId: null, sessionId: null, quantity: 3, eventOption: main },
{ eventOptionId: 'tshirt', variantId: null, sessionId: null, quantity: 4, eventOption: { isMainTicket: false } },
];
expect(tickets.formPlaces(lines)).toBe(5);
});
test('only a required form with a question to answer counts', () => {
expect(tickets.formNeedsAnswers(QUESTION_FORM)).toBe(true);
expect(tickets.formNeedsAnswers({ isRequired: true, fields: [{ type: 'statement' }, { type: 'paragraph' }] })).toBe(false);
expect(tickets.formNeedsAnswers({ isRequired: false, fields: [{ type: 'text' }] })).toBe(false);
expect(tickets.formNeedsAnswers(null)).toBe(false);
});
test('forms still needed: places less the forms in; none once left for later', async () => {
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
mockDb.formResponse.count.mockResolvedValue(1);
expect(await tickets.formsOutstanding(reg({}, 3))).toBe(2);
expect(await tickets.formsOutstanding(reg({ formWaivedAt: new Date() }, 3))).toBe(0);
});
});
describe('generating tickets', () => {
test('a required form not filled in holds them back', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg());
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
expect(mockDb.ticket.create).not.toHaveBeenCalled();
});
test('…until the form is left for later', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ formWaivedAt: new Date() }));
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
});
test('a form with only statements and headings holds nothing back', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg());
mockDb.eventForm.findUnique.mockResolvedValue({ isRequired: true, fields: [{ type: 'statement' }] });
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
});
test('unpaid: none, unless an admin released them (and never when cancelled)', async () => {
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'pending' }));
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'partial_paid', ticketsReleasedAt: new Date() }));
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'cancelled', ticketsReleasedAt: new Date() }));
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
});
});
describe('Fill in later', () => {
test('stops the tickets waiting, issues and sends them, and sends the form link', async () => {
let waived: Date | null = null;
mockDb.registration.findUnique.mockImplementation(async () => reg({ formWaivedAt: waived }));
mockDb.registration.update.mockImplementation(async ({ data }: any) => { waived = data.formWaivedAt; });
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
const out = await corrections.leaveFormForLater('r1', { actor: { id: 'st', role: 'staff' } });
expect(waived).toBeInstanceOf(Date);
expect(out).toEqual({ ticketsIssued: 1, sentTo: ['email'] });
expect(mockEmailTickets).toHaveBeenCalledTimes(1);
expect(mockReminder).toHaveBeenCalledWith('r1');
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'attendee_form_waived', targetId: 'r1' }));
});
});
describe('issuing tickets as an admin', () => {
test('unpaid needs "pay later"', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
await expect(corrections.issueTickets('r1', admin, {})).rejects.toThrow('isn\'t paid yet');
});
test('a form still outstanding is refused unless it\'s left for later — and nothing is changed', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
await expect(corrections.issueTickets('r1', admin, { payLater: true })).rejects.toThrow('2 attendee forms are still to fill in');
expect(mockDb.registration.update).not.toHaveBeenCalled();
});
test('pay later releases them with the balance still owed', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
mockDb.event.findUnique.mockResolvedValue({ id: 'ev1', scheduleType: 'single', sessions: [], form: null });
await corrections.issueTickets('r1', admin, { payLater: true, reason: 'Pays on Friday' }).catch(() => undefined);
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ ticketsReleasedAt: expect.any(Date), ticketsReleasedById: 'adm' }) }));
expect(mockDb.registration.update.mock.calls[0][0].data.status).toBeUndefined();
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'tickets_issued_override', metadata: expect.objectContaining({ payLater: true, reason: 'Pays on Friday' }) }));
});
test('make it free writes the balance off as a discount and marks it paid', async () => {
mockDb.registration.findUnique.mockImplementation(async (q: any) => (q.include?.payments
? { ...reg({ status: 'partial_paid' }), complimentaryDiscount: 0, payments: [{ amount: 100 }] }
: reg({ status: 'partial_paid' })));
mockDb.event.findUnique.mockResolvedValue({ id: 'ev1', scheduleType: 'single', sessions: [], form: null });
const out = await corrections.makeComplimentary('r1', admin, { reason: 'Speaker' }).catch((e: Error) => e);
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ complimentaryDiscount: 200 }) }));
expect(mockRecalc).toHaveBeenCalledWith('r1');
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) }));
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'registration_complimentary', metadata: expect.objectContaining({ amount: 200 }) }));
expect(out).toMatchObject({ amount: 200 });
});
});
describe('check-in after the event', () => {
const ticket = (extra: object = {}) => ({
id: 't1', eventId: 'ev1', quantity: 2, usages: [], event: { id: 'ev1', scheduleType: 'single' },
registrationOption: { sessionId: null, sessionIds: [], registration: { status: 'paid' } }, ...extra,
});
test('checks everyone left in as a manual check-in, with no window or closed-event check', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ usages: [{ id: 'u0', quantityRedeemed: 1, sessionId: null }] }));
mockDb.ticketUsage.findMany.mockResolvedValue([{ quantityRedeemed: 1, sessionId: null }, { quantityRedeemed: 1, sessionId: null }]);
const out = await corrections.correctCheckIn('t1', admin, {});
expect(mockDb.ticketUsage.create).toHaveBeenCalledWith({ data: { ticketId: 't1', scannedById: 'adm', quantityRedeemed: 1, sessionId: null, manual: true } });
expect(mockDb.ticket.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ isUsed: true }) }));
expect(out).toEqual({ checkedIn: 2, quantity: 2 });
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'checkin_corrected' }));
});
test('undo removes the check-ins; a full ticket can\'t be checked in again', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ usages: [{ id: 'u0', quantityRedeemed: 2, sessionId: null }] }));
await expect(corrections.correctCheckIn('t1', admin, {})).rejects.toThrow('already checked in');
mockDb.ticketUsage.findMany.mockResolvedValue([]);
await corrections.correctCheckIn('t1', admin, { present: false });
expect(mockDb.ticketUsage.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['u0'] } } });
});
test('multi-day: the session must be the event\'s and on the ticket', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ event: { id: 'ev1', scheduleType: 'sessions' }, registrationOption: { sessionId: 's1', sessionIds: [], registration: { status: 'paid' } } }));
await expect(corrections.correctCheckIn('t1', admin, {})).rejects.toThrow('Choose one of this event');
mockDb.eventSession.findUnique.mockResolvedValue({ id: 's2', eventId: 'ev1', cancelledAt: null });
await expect(corrections.correctCheckIn('t1', admin, { sessionId: 's2' })).rejects.toThrow('isn\'t booked for this session');
mockDb.eventSession.findUnique.mockResolvedValue({ id: 's1', eventId: 'ev1', cancelledAt: null });
mockDb.ticketUsage.findMany.mockResolvedValue([{ quantityRedeemed: 2, sessionId: 's1' }]);
await corrections.correctCheckIn('t1', admin, { sessionId: 's1' });
expect(mockDb.ticketUsage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ sessionId: 's1', manual: true, quantityRedeemed: 2 }) });
});
});
+4
View File
@@ -3,6 +3,10 @@ const { allowedWhileImpersonating, impersonationBlocker } = require('../src/util
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
// Ending (and logging) a viewing-as session works on v2 too; other v2 writes stay blocked.
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/log')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/payments')).toBe(false);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import request from 'supertest';
const mockCalls: { name: string; body?: unknown; query?: unknown }[] = [];
jest.mock('../src/controllers/webhookController', () => ({ handleYocoWebhook: (req: { rawBody: string }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'yoco', body: req.rawBody }); res.json({ ok: true }); } }));
jest.mock('../src/payments/webhook', () => ({ handleProviderWebhook: (id: string) => (_req: unknown, res: { json(b: unknown): void }) => { mockCalls.push({ name: id }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/whatsappController', () => ({ handleWebhook: (req: { body: unknown; query: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'whatsapp', body: req.body, query: req.query }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/privacyController', () => ({
oneClickRedirect: (req: { params: { token: string } }, res: { redirect(c: number, u: string): void }) => res.redirect(302, `https://site.example/unsubscribe?t=${req.params.token}`),
oneClickUnsubscribe: (req: { params: { token: string }; body: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'one-click', body: req.body }); res.json({ ok: true }); },
}));
import { webhooksRouter, linksRouter, legacyLinkRedirects } from '../src/v2/links';
// The same order and raw-body handling as app.js.
function app() {
const a = express();
a.use((req, _res, next) => {
if (req.path.startsWith('/api/v2/webhooks')) {
let raw = '';
req.setEncoding('utf8');
req.on('data', (c: string) => { raw += c; });
req.on('end', () => { (req as unknown as { rawBody: string }).rawBody = raw; next(); });
} else next();
});
a.use('/api/v2/webhooks', webhooksRouter);
a.use(express.json());
a.use('/api/v2', linksRouter);
a.use(legacyLinkRedirects);
a.post('/api/privacy/unsubscribe/:token/one-click', (_req, res) => { mockCalls.push({ name: 'v1 one-click' }); res.json({ ok: true }); });
return a;
}
beforeEach(() => { mockCalls.length = 0; });
describe('incoming webhooks on v2', () => {
test('Yoco gets the exact bytes it signed', async () => {
const body = '{"type":"payment.succeeded","payload":{"id":"p1"}}';
await request(app()).post('/api/v2/webhooks/yoco').set('Content-Type', 'application/json').send(body);
expect(mockCalls).toEqual([{ name: 'yoco', body }]);
});
test('PayFast reaches the PayFast handler', async () => {
await request(app()).post('/api/v2/webhooks/payfast').type('form').send('m_payment_id=c1');
expect(mockCalls[0].name).toBe('payfast');
});
test('WhatsApp gets its JSON parsed and keeps the secret in the query', async () => {
await request(app()).post('/api/v2/webhooks/whatsapp?secret=s3').send({ event: 'message', payload: { body: 'hi' } });
expect(mockCalls[0]).toEqual({ name: 'whatsapp', body: { event: 'message', payload: { body: 'hi' } }, query: { secret: 's3' } });
});
});
describe('links in sent messages', () => {
test('an old calendar link answers 308 with its v2 address', async () => {
const res = await request(app()).get('/api/events/e1/ics?x=1');
expect(res.status).toBe(308);
expect(res.headers.location).toBe('/api/v2/events/e1/calendar.ics?x=1');
});
test('opening an old one-click link goes to v2, which sends the person to the page', async () => {
const old = await request(app()).get('/api/privacy/unsubscribe/tok/one-click');
expect(old.status).toBe(308);
expect(old.headers.location).toBe('/api/v2/unsubscribe/tok/one-click');
const v2 = await request(app()).get('/api/v2/unsubscribe/tok/one-click');
expect(v2.status).toBe(302);
expect(v2.headers.location).toBe('https://site.example/unsubscribe?t=tok');
});
test("a mail app's one-click POST works on v2, and on the old address without a redirect", async () => {
await request(app()).post('/api/v2/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(mockCalls[0]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
const old = await request(app()).post('/api/privacy/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(old.status).toBe(200);
expect(mockCalls[1].name).toBe('v1 one-click');
});
});
+1 -1
View File
@@ -193,7 +193,7 @@ describe('PayFast provider', () => {
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
notify_url: 'https://api.example.org/api/v2/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
+1 -1
View File
@@ -97,7 +97,7 @@ describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/v2\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
+2
View File
@@ -51,7 +51,9 @@ jest.mock('../src/utils/campusScope', () => ({
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
listWhere: async () => ({}),
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
scopeBroadcast: () => (_req: unknown, _res: unknown, next: () => void) => next(),
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
campusIndependent: () => (_req: unknown, _res: unknown, next: () => void) => next(),
}));
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
+25
View File
@@ -72,4 +72,29 @@ describe('v2 routes answered by v1 handlers', () => {
expect(res.headers['content-disposition']).toContain('ticket.pdf');
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
});
test("a v1 router is given the v1 URL; one with no matching route is a 404", async () => {
const router = express.Router();
router.get('/today', (req, res) => { res.json({ q: req.query.x }); });
const app = appWith(
v1Route({ method: 'get', path: '/dash', tag: 'T', summary: 's', access: 'public', v1: router, map: ctx => ({ url: '/today?x=' + String(ctx.req.query.x) }) }),
v1Route({ method: 'get', path: '/nothing', tag: 'T', summary: 's', access: 'public', v1: router, map: () => ({ url: '/missing' }) }),
);
expect((await request(app).get('/api/v2/dash?x=7')).body).toEqual({ data: { q: '7' } });
const res = await request(app).get('/api/v2/nothing');
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
test("a file v1 sends with res.download is a download", async () => {
const os = require("os"); const path = require("path");
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
require("fs").writeFileSync(file, "-- backup");
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toContain("backup.sql");
expect((res.body as Buffer).toString()).toBe("-- backup");
});
});
+2 -2
View File
@@ -82,12 +82,12 @@ describe('webhook URL', () => {
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/v2/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/v2/webhooks/yoco');
});
test('must be public https', () => {
+54 -35
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.11** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-11-02, after **1.52.0** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,15 +14,24 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.52.0 | **Urgent fix + Corrections:** required attendee forms no longer leave paid door/manual registrations without tickets — popup with "Fill in later" (`Registration.formWaivedAt`, `POST /api/v2/registrations/{id}/form-later`); one form per person on picked sessions; forms with no questions don't hold tickets; kiosk asks for the forms the server says are missing. Admin **Corrections** page (`src/services/corrections.ts`, `/api/v2/corrections/…`): issue missing tickets, pay later (`ticketsReleasedAt`), make it free (`complimentaryDiscount`), check in after the event. Migration `20261102120000_corrections`. e2e `e2e/corrections.spec.ts` |
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
| 1.51.12 | **Tickets and attendance on v2** — `resources/attendance.ts`; frontend `apiV2Raw()` for string-path helpers |
| 1.51.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
| 1.51.10 | **Managing registrations on v2** — `src/services/registrationAdmin.ts` (the rest of `registrationController.js`); frontend `fetchAllRegistrations()`; money split out to 1.51.11 |
| 1.51.9 | **Series, season passes, bundles, coupons, campuses on v2** — `src/services/series.ts`, `deals.ts`, `campuses.ts`; `reason` on v2 errors from service codes; frontend `apiV2Data()` |
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
| 1.51.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
@@ -38,9 +47,9 @@ encrypted settings unreadable or break existing installs.
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
@@ -54,10 +63,6 @@ encrypted settings unreadable or break existing installs.
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
| 1.32.0 | Early-bird ribbon on event cards |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
@@ -65,25 +70,37 @@ encrypted settings unreadable or break existing installs.
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), `scripts/deploy.sh` |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.32.0 | Early-bird ribbon on event cards |
## Next up
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.12 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
in charged rand. AI endpoints are `/api/v2` only.
1. **2.0.0** — AI helpers (§15), v1 removed, a migration guide, and marketing site/docs for
selling. Every endpoint is on v2 since 1.51.16. Keep answering the v1 webhook paths and the
old links in sent messages (`src/v2/links.ts`) after v1 goes: providers and old emails still
use them.
2. **AI rules** (decided 2026-10-29, roadmap §15): the AI buttons show only when the licence's
monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
rand. AI endpoints are `/api/v2` only. Check Claude Haiku 5.5's current prices before setting
the markup and caps.
3. **Clean-ups before or with 2.0:**
- Turn the React Compiler lint rules back to errors (see [Next.js 16](#nextjs-16-1515)) — every
screen is on v2 now, but they're still `warn` in `frontend/eslint.config.mjs`.
- `node-fetch` is still listed in `backend/package.json` though nothing in `src/` uses it —
remove it.
- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still `bcryptjs`.
Decide whether it's in 2.0.
4. **Later:** move from ExcelTS — deprecated in favour of `documonster` by the same author
(pre-1.0 in Oct 2026) — once documonster is stable. After 1.51.7 has run everywhere, the old
`pgboss` schema can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026-10-28).
## Working on this repo
@@ -171,8 +188,8 @@ a test server (both done 2026-10-28).
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
(`routes/webhookRoutes.js`), and give it a settings card in
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there),
and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`.
@@ -189,7 +206,8 @@ a test server (both done 2026-10-28).
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
`/api/whatsapp/webhook?secret=…`; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook` or `/api/webhooks/whatsapp`, which run the same handler
since 1.51.16; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
- **Anything new that stores personal data** must be added to `anonymiseUser` and
@@ -462,7 +480,8 @@ a test server (both done 2026-10-28).
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
payments, audit log, consent/access logs, data requests, My payments.
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
@@ -516,7 +535,7 @@ a test server (both done 2026-10-28).
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
`middleware`; check `req.multerError`/`req.file` in the handler).
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
email/WhatsApp senders (messaging, 1.51.14).
email/WhatsApp senders — all on v2 since 1.51.14 (`v1Route`).
## Setting events up on v2 (1.51.7)
@@ -543,8 +562,9 @@ a test server (both done 2026-10-28).
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
mostly `useEffect(() => { load(); }, [...])`. The plan was to fix them as each screen moved onto
v2; every screen is on v2 now (1.51.15) but the rules are still `warn` — fix what's left and turn
them back to errors before or with 2.0. Don't add new ones.
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
@@ -589,13 +609,14 @@ a test server (both done 2026-10-28).
## Event picker and webhook guide (1.50.2)
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
Give it the whole list (`fetchManagedEvents(token, { past: true })`, v2 `GET /events/manage`
since 1.51.7); it opens on upcoming events, keeps
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
`pickEvent(page, title)` from `e2e/fixtures.ts`.
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
a slim `?fields=` mode would help if a church gets into the thousands.
- The managed events list still returns full events (options, form, sessions) — fine for a few
hundred; a slim `?fields=` mode would help if a church gets into the thousands.
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
shape.
@@ -606,8 +627,6 @@ a test server (both done 2026-10-28).
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
link in the message).
- `/api/webhooks/whatsapp` (`webhookController.handleWhatsappWebhook`) is an old empty stub; the
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
+40 -43
View File
@@ -11,39 +11,35 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**Status (2026-10-27):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2** and 1.51.11 **money on v2** shipped.
**Next: 1.51.12 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
**Status (2026-10-30):** all features in this document are approved, and everything before 2.0.0
has shipped (1.32.0 – 1.51.17). **Next: 2.0.0** — AI helpers (§15) and v1 removed. The full
version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
- **Multi-day events** (§1): 1.33.0 phase A (sessions, late joining, per-session check-in,
attendance register, reminders, certificates); 1.35.0 phase B (pick sessions, per-session
capacity); 1.35.1 per-session waitlist, session picker at the door.
- **Platform:** 1.34.0 job queue (pg-boss), Playwright E2E tests and deploy script (the Gitea CI
workflow was removed in 1.46.1 — no runner).
- **Payments** (§2): 1.36.0 provider interface (Yoco behind it); 1.37.0 – 1.37.2 PayFast, "Pay
online", payment status checks. SnapScan and Zapper dropped (2026-10-23).
- **POPIA toolkit** (§3): 1.38.0 – 1.40.0.
- **Hub** (§13): 1.41.0 connection (licence, heartbeat, read-only mode, `/health`, install
script) with Hub 1.0.0; 1.41.3 renamed **Hope Events → Cross Events**; 1.42.0 **Request
update** with Hub 1.1.0; 1.42.1 production Hub key built in.
- **Multi-campus** (§12): 1.43.0 campuses; 1.44.0 campus-scoped staff.
- **Kids check-in** (§4) 1.45.0; **volunteer rosters** (§5) 1.46.0.
- **Event series and cloning** (§6): 1.47.0 copy an event, 1.47.1 series, 1.47.2 year-on-year
report, 1.47.3 season passes.
- **Outbound webhooks** (§8) 1.48.0.
- **Dashboard, palette, bulk actions, saved filters** (§9): 1.49.0 – 1.49.2.
- **Accessibility** (§11) 1.50.0; 1.50.1 long lists; 1.50.2 event picker and webhook guide.
- **TypeScript backend and `/api/v2`** (§14): 1.51.0 – 1.51.3 foundations, registrations and
payments, accounts and check-in, Express 5; 1.51.4 security and dependency updates; 1.51.5
Next.js 16; 1.51.6 command palette and ExcelTS; 1.51.7 – 1.51.16 every remaining area on v2 —
**every endpoint is on v2**.
- 1.51.17 security fix: forgot password and sign-in don't reveal whether an account exists.
Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
**Repositories:**
@@ -831,13 +827,13 @@ keep adding to.
| Item | What |
|------|------|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
| ~~CI (Gitea Actions)~~ | Added in 1.34.0, **removed in 1.46.1** (no machine to run a runner): lint, tests and deploy are done by hand |
| Playwright E2E ✅ | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk (1.34.0) |
| Job queue ✅ | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin (1.34.0; pg-boss 11 in 1.51.7) |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite. Partly done (1.51.1 – 1.51.10); the handlers behind `v1Route` are a 2.x clean-up |
| TypeScript backend ✅ | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend (1.51.0) |
| API v2 ✅ | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition; every endpoint on v2 since 1.51.16 |
| Dependencies | Express 5 ✅ (1.51.3); native `fetch` ✅ (nothing uses `node-fetch` any more — it's still in `backend/package.json`, to remove); password hashing upgrade with rehash-on-login — **not done** (still `bcryptjs`), decide whether it's in 2.0 |
---
@@ -866,7 +862,8 @@ never saves, sends or publishes by itself.
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
current model ($0.10 per million input tokens, $0.50 per million output, for
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
prompts under 100K tokens; prices as of 2026-10 — **check them against Anthropic's pricing
page before setting the markup and caps**). Good enough for drafting and
summarising; a helper can move to a bigger model later if quality needs it.
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
@@ -1002,7 +999,7 @@ income justify the hardware:
|-------|---------|----------|-----|
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
@@ -1012,7 +1009,7 @@ income justify the hardware:
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅ – 1.51.11 ✅, 1.51.12 – 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
+101 -2
View File
@@ -90,8 +90,18 @@ test.describe("API v2", () => {
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
// Endpoints with no v2 version yet say nothing.
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
// Incoming webhooks: the v1 address still works (same handler) and names its v2 one.
const hook = await request.post(`${API}/api/webhooks/whatsapp`, { data: {} });
expect(hook.status()).toBe(200);
expect(hook.headers()["link"]).toBe(`</api/v2/webhooks/whatsapp>; rel="successor-version"`);
expect((await request.post(`${API}/api/v2/webhooks/whatsapp`, { data: { event: "noop" } })).status()).toBe(200);
// A calendar link in an email already sent goes to its v2 address.
const ics = await request.get(`${API}/api/events/${seed().events.conference}/ics`, { maxRedirects: 0 });
expect(ics.status()).toBe(308);
expect(ics.headers()["location"]).toBe(`/api/v2/events/${seed().events.conference}/calendar.ics`);
const followed = await request.get(`${API}/api/events/${seed().events.conference}/ics`);
expect(followed.headers()["content-type"]).toContain("text/calendar");
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
@@ -240,6 +250,95 @@ test.describe("API v2", () => {
}
});
test("tickets and attendance: mine, the register, a spreadsheet download, links that don't exist", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const mine = await request.get(`${API}/api/v2/me/tickets`, { headers: user });
expect(mine.status()).toBe(200);
expect(Array.isArray((await mine.json()).data)).toBe(true);
const reg = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance`, { headers: staff });
expect(reg.status()).toBe(200);
const xlsx = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance/export?format=xlsx`, { headers: staff });
expect(xlsx.status()).toBe(200);
expect(xlsx.headers()["content-type"]).toContain("spreadsheetml");
expect(xlsx.headers()["content-disposition"]).toContain(".xlsx");
expect((await xlsx.body()).subarray(0, 2).toString()).toBe("PK");
// A code that isn't a certificate is an answer, not an error.
expect((await (await request.get(`${API}/api/v2/certificates/verify/NO-SUCH-CODE`)).json()).data).toEqual({ valid: false });
const transfer = await request.get(`${API}/api/v2/ticket-transfers/not-a-real-token`);
expect(transfer.status()).toBeGreaterThanOrEqual(400);
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
});
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
const res = await request.get(`${API}${path}`, { headers });
expect(res.status(), path).toBe(200);
expect(await res.json(), path).toHaveProperty("data");
}
// Staff lists aren't for everyone.
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
});
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
expect(Array.isArray(listing)).toBe(true);
expect(listing[0]).toHaveProperty("eventOptions");
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
const res = await request.get(`${API}${path}`, { headers: staff });
expect(res.status(), path).toBe(200);
}
// Saved filters run the v1 router: save one, list it, delete it.
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
expect(saved.status()).toBe(200);
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
expect(mine).toBeTruthy();
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
expect(exported.status()).toBe(200);
expect(exported.headers()["content-disposition"]).toContain("my-data-");
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
const res = await request.get(`${API}${path}`, { headers: admin });
expect(res.status(), path).toBe(200);
}
// Admin areas aren't for everyone.
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
+126
View File
@@ -0,0 +1,126 @@
import { test, expect, loginAs, api, seed, pickEvent } from "./fixtures";
import type { Page } from "@playwright/test";
// 1.52.0: a required attendee form no longer leaves paid door registrations without tickets,
// and admins can put things right afterwards (Corrections).
type Reg = { id: string; status: string; formsNeeded?: number; registrationOptions: { tickets?: { id: string }[] }[] };
type Row = { id: string; user: { name: string }; missingTickets: boolean; formsNeeded: number; balance: number; status: string; tickets: { id: string; checkedIn: Record<string, number> }[] };
const ticketsOn = (r: Reg) => r.registrationOptions.reduce((n, o) => n + (o.tickets || []).length, 0);
async function register(page: Page, name: string, email: string): Promise<Reg> {
const res = await api<{ data: Reg }>(page, "supervisor", "POST", "/api/v2/registrations/manual", {
eventId: seed().events.form, options: [{ eventOptionId: seed().formOptionId, quantity: 1 }],
user: { name, email }, skipActivationNotice: true,
});
return res.data;
}
const full = async (page: Page, id: string) => (await api<{ data: Reg }>(page, "supervisor", "GET", `/api/v2/registrations/${id}/full`)).data;
const pay = (page: Page, id: string, amount = 200) => api<{ data: { formsNeeded?: number } }>(page, "supervisor", "POST", "/api/v2/payments", { registrationId: id, amount, method: "cash" });
const rows = async (page: Page, eventId: string) => (await api<{ data: { registrations: Row[] } }>(page, "admin", "GET", `/api/v2/corrections/events/${eventId}`)).data.registrations;
test.describe("required attendee forms at the door", () => {
test("paid at the door: the form holds the tickets back until it's filled in or left for later", async ({ page }) => {
const reg = await register(page, "Door Formless", "door-formless@e2e.test");
expect(reg.formsNeeded).toBe(1);
const paid = await pay(page, reg.id);
expect(paid.data.formsNeeded).toBe(1);
expect(ticketsOn(await full(page, reg.id))).toBe(0);
const later = await api<{ data: { ticketsIssued: number } }>(page, "staff", "POST", `/api/v2/registrations/${reg.id}/form-later`, { notify: false });
expect(later.data.ticketsIssued).toBe(1);
expect(ticketsOn(await full(page, reg.id))).toBe(1);
});
test("at the door the form pops up; filling it in issues the tickets once paid", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
await page.waitForLoadState("networkidle");
await pickEvent(page, "E2E Camp Weekend");
await page.waitForLoadState("networkidle");
await page.getByPlaceholder("Name, email or phone…").fill("Door Camper");
await page.getByText('New attendee "Door Camper"…').click();
await page.getByPlaceholder("Email address").fill("door-camper@e2e.test");
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await page.getByRole("button", { name: "Confirm Registration" }).click();
const popup = page.getByRole("dialog", { name: /Attendee form/ });
await expect(popup).toBeVisible();
await expect(popup.getByText("About you")).toBeVisible();
await popup.getByRole("button", { name: "Save form" }).click();
await expect(popup.getByText('"Emergency contact" is required.')).toBeVisible();
await popup.getByLabel(/Emergency contact/).fill("Mom 082 000 0000");
await popup.getByRole("button", { name: "Save form" }).click();
await expect(popup).toBeHidden();
await expect(page.getByText("Attendee form saved.")).toBeVisible();
await page.getByRole("button", { name: "Full" }).click();
await page.getByRole("button", { name: "Capture Payment" }).click();
await expect(page.getByText("Payment recorded — tickets sent.")).toBeVisible();
await expect(page.getByRole("button", { name: /^Check In 1/ })).toBeVisible();
});
test("self-service kiosk: the visitor fills in the form and is registered", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await pickEvent(page, "E2E Camp Weekend");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await page.getByPlaceholder("john@example.com").fill("kiosk-camper@e2e.test");
await page.getByPlaceholder("John Smith").fill("Kiosk Camper");
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Next — Fill in Form" }).click();
await expect(page.getByRole("heading", { name: "Attendee Information" })).toBeVisible();
// A heading is text to read, not a box to fill in.
await expect(page.getByText("About you")).toBeVisible();
await expect(page.locator("textarea")).toHaveCount(0);
await page.getByLabel(/Emergency contact/).fill("Dad 082 111 1111");
await page.getByRole("button", { name: "Complete Registration" }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
});
test.describe("Corrections (admins)", () => {
test("issue every missing ticket at an event in one go", async ({ page }) => {
const reg = await register(page, "Missing Tickets", "missing-tickets@e2e.test");
await pay(page, reg.id);
expect((await rows(page, seed().events.form)).find(r => r.id === reg.id)).toMatchObject({ missingTickets: true, formsNeeded: 1 });
const out = await api<{ data: { registrations: number; tickets: number } }>(page, "admin", "POST", `/api/v2/corrections/events/${seed().events.form}/issue-missing`, {});
expect(out.data.tickets).toBeGreaterThanOrEqual(1);
expect((await rows(page, seed().events.form)).find(r => r.id === reg.id)).toMatchObject({ missingTickets: false, formsNeeded: 0 });
});
test("tickets before payment, and making a balance free", async ({ page }) => {
const later = await register(page, "Pays Later", "pays-later@e2e.test");
const res = await api<{ data: { ticketsIssued: number; registration: Row } }>(page, "admin", "POST", `/api/v2/corrections/registrations/${later.id}/tickets`, { payLater: true, skipForm: true, reason: "Pays on Friday" });
expect(res.data.ticketsIssued).toBe(1);
expect(res.data.registration).toMatchObject({ status: "pending", balance: 200 });
const comp = await register(page, "On The House", "on-the-house@e2e.test");
await pay(page, comp.id, 50);
const free = await api<{ data: { amount: number; registration: Row } }>(page, "admin", "POST", `/api/v2/corrections/registrations/${comp.id}/complimentary`, { skipForm: true, reason: "Speaker" });
expect(free.data.amount).toBe(150);
expect(free.data.registration).toMatchObject({ status: "paid", balance: 0 });
expect(free.data.registration.tickets).toHaveLength(1);
// Not for staff or supervisors.
await expect(api(page, "supervisor", "GET", `/api/v2/corrections/events/${seed().events.form}`)).rejects.toThrow("403");
});
test("check someone in after the event is over", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/dashboard/admin/corrections?event=${seed().events.past}`);
await page.getByRole("button", { name: /^Not checked in/ }).click();
const card = page.locator("div.border.rounded-xl", { hasText: "Lerato Late" });
await expect(card.getByText("Checked in: 0/2")).toBeVisible();
await card.getByRole("button", { name: "Check in" }).click();
await expect(page.getByText("Lerato Late: checked in.")).toBeVisible();
const [row] = (await rows(page, seed().events.past)).filter(r => r.user.name === "Lerato Late");
expect(row.tickets[0].checkedIn.event).toBe(2);
});
});
+2 -1
View File
@@ -5,9 +5,10 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string; form: string; past: string };
campuses: Record<"north" | "south", { id: string; slug: string }>;
northTicketQr: string;
formOptionId: string;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
+33 -1
View File
@@ -187,11 +187,43 @@ async function seed(url) {
await generateTicketsForRegistration(northReg.id);
const northTicket = await prisma.ticket.findFirst({ where: { registrationOption: { registrationId: northReg.id } } });
// Corrections (corrections.spec.ts): a paid camp with a required attendee form, and an event
// that ended yesterday with someone who was never checked in. Hidden, like kids/serving.
const formEvent = await prisma.event.create({
data: {
title: 'E2E Camp Weekend', description: 'A camp with a required attendee form.', startDate: at(5, 7), endDate: at(6, 15),
price: 200, isHidden: true, createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Camp ticket', price: 200, isMainTicket: true }] },
form: { create: { isRequired: true, fields: { create: [
{ type: 'paragraph', label: 'About you', order: 0 },
{ type: 'text', label: 'Emergency contact', isRequired: true, order: 1 },
] } } },
},
include: { eventOptions: true },
});
const pastEvent = await prisma.event.create({
data: {
title: 'E2E Last Sunday', description: 'It ended yesterday.', startDate: at(-1, 7), endDate: at(-1, 10),
price: 0, isHidden: true, createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
include: { eventOptions: true },
});
const latecomer = await mk('user', 'Lerato Late', 'late@e2e.test', '0820000009');
const lateReg = await prisma.registration.create({
data: {
userId: latecomer.id, eventId: pastEvent.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: pastEvent.eventOptions[0].id, quantity: 2, priceSnapshot: 0, tranches: { create: [{ quantity: 2, priceSnapshot: 0 }] } }] },
},
});
await generateTicketsForRegistration(lateReg.id);
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id, form: formEvent.id, past: pastEvent.id },
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
northTicketQr: northTicket.qrCode,
formOptionId: formEvent.eventOptions[0].id,
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.11",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.11",
"version": "1.52.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.11",
"version": "1.52.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -2,9 +2,9 @@
import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
try {
setLoading(true);
setStatus(null);
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
const res = await (apiV2Data("post", "/auth/password/forgot", { body: { email } }) as Promise<{ message?: string }>);
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
} catch (err) {
try {
+2 -2
View File
@@ -3,12 +3,12 @@ import React, { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ActivateAccountContent() {
const search = useSearchParams();
@@ -29,7 +29,7 @@ function ActivateAccountContent() {
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
const data = await (apiV2Data("post", "/auth/activate", { body: { token, password, acceptTerms: accepted } }) as Promise<{ token?: string }>);
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
+1 -1
View File
@@ -42,7 +42,7 @@ export default async function CampusPage({ params }: { params: Promise<{ slug: s
const campus = await loadCampus(slug);
if (!campus) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
@@ -2,8 +2,8 @@ import { Metadata } from "next";
import { BadgeCheck, ShieldX } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { apiV2Data } from "@/lib/apiV2";
export const dynamic = "force-dynamic";
@@ -20,7 +20,7 @@ export default async function VerifyCertificatePage({ params }: { params: Promis
const { code } = await params;
let result: Verification = { valid: false };
try {
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
result = await (apiV2Data("get", "/certificates/verify/{code}", { params: { code: code }, nextOptions: { cache: "no-store" } }) as Promise<Verification>);
} catch {
result = { valid: false };
}
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { History } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
@@ -80,6 +80,10 @@ const ACTION_LABELS: Record<string, string> = {
registrations_bulk: "Bulk action on registrations",
attendance_marked: "Attendance marked",
attendance_unmarked: "Attendance unmarked",
attendee_form_waived: "Attendee form left for later",
tickets_issued_override: "Tickets issued by an admin (correction)",
registration_complimentary: "Balance made complimentary",
checkin_corrected: "Check-in corrected after the fact",
certificate_issued: "Certificate issued",
certificate_revoked: "Certificate revoked",
event_sessions_changed: "Event sessions changed",
@@ -131,7 +135,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 +200,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 +219,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);
@@ -0,0 +1,408 @@
"use client";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Wrench, AlertTriangle, CheckCircle2, Ticket, FileText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { fetchEvent, fetchManagedEvents } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { AttendeeFormModal, type AttendeeFormDef } from "@/components/shared/AttendeeFormModal";
import type { Event } from "@/types";
// GET /api/v2/corrections/events/{id} (services/corrections.ts)
type CorrectionTicket = {
id: string; qrCode: string; label: string; isMainTicket: boolean; name: string; quantity: number;
sessionIds: string[]; checkedIn: Record<string, number>;
};
type CorrectionRow = {
id: string; status: string; createdAt: string;
user: { id: string; name: string; email: string | null; phoneNumber: string | null };
totalDue: number; paid: number; balance: number; places: number; ticketPlaces: number;
missingTickets: boolean; formsNeeded: number; formWaived: boolean; ticketsReleased: boolean; complimentary: number;
tickets: CorrectionTicket[];
};
type CorrectionEvent = { id: string; title: string; startDate: string; endDate: string; closed: boolean; formRequired: boolean; sessions: { id: string; label: string; startsAt: string }[] };
type Filter = "attention" | "unpaid" | "not_checked_in" | "all";
const WHOLE_EVENT = "event";
const rand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const notCheckedIn = (r: CorrectionRow) => r.tickets.some(t => t.isMainTicket && t.sessionIds.some(s => (t.checkedIn[s] || 0) < t.quantity));
const needsAttention = (r: CorrectionRow) => r.missingTickets || r.formsNeeded > 0 || (r.balance > 0 && r.tickets.length === 0);
/** One registration: what's wrong with it, and the fixes. */
function RegistrationCard({ row, event, form, token, onChanged, onMessage }: {
row: CorrectionRow; event: CorrectionEvent; form: AttendeeFormDef; token: string;
onChanged: () => void; onMessage: (ok: boolean, text: string) => void;
}) {
const [action, setAction] = useState<"none" | "tickets" | "free">("none");
const [skipForm, setSkipForm] = useState(row.formsNeeded > 0);
const [send, setSend] = useState(false);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [showForm, setShowForm] = useState(false);
const unpaid = row.status !== "paid" && !row.ticketsReleased;
const run = async () => {
setBusy(true);
try {
if (action === "free") {
const res = await (apiV2Data("post", "/corrections/registrations/{id}/complimentary", {
params: { id: row.id }, authToken: token, body: { skipForm, send, reason: reason || null },
}) as Promise<{ amount: number; ticketsIssued: number }>);
onMessage(true, `${row.user.name}: ${rand(res.amount)} made complimentary${res.ticketsIssued ? ` — ${res.ticketsIssued} ticket(s) issued` : ""}.`);
} else {
const res = await (apiV2Data("post", "/corrections/registrations/{id}/tickets", {
params: { id: row.id }, authToken: token, body: { skipForm, payLater: unpaid, send, reason: reason || null },
}) as Promise<{ ticketsIssued: number }>);
onMessage(true, `${row.user.name}: ${res.ticketsIssued ? `${res.ticketsIssued} ticket(s) issued` : "tickets are up to date"}${unpaid ? " — the balance is still owed" : ""}.`);
}
setAction("none");
setReason("");
onChanged();
} catch (e) {
onMessage(false, errorMessage(e) || "That didn't work.");
} finally {
setBusy(false);
}
};
const chips: { text: string; tone: string }[] = [];
if (row.missingTickets) chips.push({ text: `Missing tickets (${row.ticketPlaces} of ${row.places})`, tone: "bg-red-50 text-red-700" });
if (row.formsNeeded > 0) chips.push({ text: `${row.formsNeeded} form${row.formsNeeded === 1 ? "" : "s"} to fill in`, tone: "bg-amber-50 text-amber-700" });
if (row.balance > 0) chips.push({ text: `Owes ${rand(row.balance)}`, tone: "bg-orange-50 text-orange-700" });
if (row.formWaived) chips.push({ text: "Form left for later", tone: "bg-gray-100 text-gray-600" });
if (row.ticketsReleased) chips.push({ text: "Tickets issued before payment", tone: "bg-blue-50 text-blue-700" });
if (row.complimentary > 0) chips.push({ text: `Complimentary ${rand(row.complimentary)}`, tone: "bg-emerald-50 text-emerald-700" });
return (
<div className="border rounded-xl bg-white shadow-sm p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-gray-900">{row.user.name || "Guest"}</div>
<div className="text-xs text-gray-500 break-all">{[row.user.email, row.user.phoneNumber].filter(Boolean).join(" · ") || "No contact details"}</div>
</div>
<div className="text-right text-xs text-gray-600">
<div><span className="font-medium capitalize">{row.status.replace("_", " ")}</span> · {rand(row.paid)} of {rand(row.totalDue)}</div>
<div className="text-gray-400">#{row.id.slice(0, 8)}</div>
</div>
</div>
{chips.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{chips.map(c => <span key={c.text} className={`text-[11px] px-2 py-0.5 rounded-full ${c.tone}`}>{c.text}</span>)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-3">
{row.formsNeeded > 0 && (
<button type="button" onClick={() => setShowForm(true)} className="px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 inline-flex items-center gap-1">
<FileText className="w-3.5 h-3.5" /> Fill in form
</button>
)}
{(row.missingTickets || unpaid || row.formsNeeded > 0) && (
<button type="button" onClick={() => { setAction(action === "tickets" ? "none" : "tickets"); setSkipForm(row.formsNeeded > 0); }} className={`px-3 py-1.5 text-xs rounded border inline-flex items-center gap-1 ${action === "tickets" ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
<Ticket className="w-3.5 h-3.5" /> {unpaid ? "Issue tickets — pay later" : "Issue tickets"}
</button>
)}
{row.balance > 0 && !event.closed && (
<button type="button" onClick={() => { setAction(action === "free" ? "none" : "free"); setSkipForm(row.formsNeeded > 0); }} className={`px-3 py-1.5 text-xs rounded border ${action === "free" ? "bg-emerald-600 text-white border-emerald-600" : "bg-white hover:bg-gray-50"}`}>
Make it free ({rand(row.balance)})
</button>
)}
</div>
{action !== "none" && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50 space-y-2 text-sm">
<p className="text-xs text-gray-600">
{action === "free"
? `${rand(row.balance)} is written off as a complimentary discount — income stays ${rand(row.paid)} — and the registration is marked paid.`
: unpaid
? `The tickets are issued now. ${rand(row.balance)} is still owed and shows as outstanding until it's paid.`
: "Issues the tickets this registration is missing."}
</p>
{row.formsNeeded > 0 && (
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={skipForm} onChange={e => setSkipForm(e.target.checked)} />
Leave the attendee form for later (otherwise the tickets wait for it)
</label>
)}
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={send} onChange={e => setSend(e.target.checked)} />
Send the tickets to {row.user.name || "the attendee"}
</label>
<label className="block text-xs">
<span className="text-gray-600">Reason (kept in the audit log)</span>
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm bg-white" value={reason} onChange={e => setReason(e.target.value)} placeholder={action === "free" ? "e.g. Speaker's spouse" : "e.g. Paid at the door, form on paper"} maxLength={500} />
</label>
<div className="flex gap-2">
<button type="button" disabled={busy} onClick={run} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white disabled:opacity-50">{busy ? "Working…" : action === "free" ? "Make it free" : "Issue tickets"}</button>
<button type="button" disabled={busy} onClick={() => setAction("none")} className="px-3 py-1.5 text-xs rounded border bg-white">Cancel</button>
</div>
</div>
)}
{row.tickets.length > 0 && (
<div className="mt-3 space-y-2">
{row.tickets.map(t => <TicketCheckIn key={t.id} ticket={t} event={event} token={token} onChanged={onChanged} onMessage={onMessage} />)}
</div>
)}
{showForm && (
<AttendeeFormModal
registrationId={row.id}
form={form}
needed={row.formsNeeded}
token={token}
attendeeName={row.user.name}
onDone={({ later, ticketsIssued }) => {
setShowForm(false);
onMessage(true, `${row.user.name}: ${later ? "form left for later" : "form saved"}${ticketsIssued ? ` — ${ticketsIssued} ticket(s) issued` : ""}.`);
onChanged();
}}
/>
)}
</div>
);
}
/** A ticket's check-ins per session, with check in / undo whatever the time. */
function TicketCheckIn({ ticket, event, token, onChanged, onMessage }: {
ticket: CorrectionTicket; event: CorrectionEvent; token: string;
onChanged: () => void; onMessage: (ok: boolean, text: string) => void;
}) {
const [busyKey, setBusyKey] = useState<string | null>(null);
const label = (key: string) => key === WHOLE_EVENT ? "Checked in" : (event.sessions.find(s => s.id === key)?.label || "Session");
const change = async (key: string, present: boolean) => {
setBusyKey(key);
try {
await apiV2Data("post", "/corrections/tickets/{id}/check-in", {
params: { id: ticket.id }, authToken: token,
body: { sessionId: key === WHOLE_EVENT ? null : key, present },
});
onMessage(true, `${ticket.name}: ${present ? "checked in" : "check-in undone"}${key === WHOLE_EVENT ? "" : ` for ${label(key)}`}.`);
onChanged();
} catch (e) {
onMessage(false, errorMessage(e) || "That didn't work.");
} finally {
setBusyKey(null);
}
};
return (
<div className="border rounded-lg px-3 py-2 text-xs">
<div className="flex flex-wrap justify-between gap-1">
<span className="font-medium text-gray-800">{ticket.label}{ticket.quantity > 1 ? ` ×${ticket.quantity}` : ""}</span>
<span className="text-gray-500">{ticket.name}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-2">
{ticket.sessionIds.map(key => {
const n = ticket.checkedIn[key] || 0;
const full = n >= ticket.quantity;
return (
<span key={key} className="inline-flex items-center gap-1.5 border rounded-full pl-2 pr-1 py-0.5">
{full ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> : null}
<span className={full ? "text-emerald-700" : "text-gray-600"}>{label(key)}: {n}/{ticket.quantity}</span>
{!full && (
<button type="button" disabled={busyKey === key} onClick={() => change(key, true)} className="px-2 py-0.5 rounded-full bg-brand-600 text-white disabled:opacity-50">
Check in
</button>
)}
{n > 0 && (
<button type="button" disabled={busyKey === key} onClick={() => change(key, false)} className="px-2 py-0.5 rounded-full border bg-white disabled:opacity-50">
Undo
</button>
)}
</span>
);
})}
</div>
</div>
);
}
function CorrectionsContent() {
const { user, token, loading } = useAuth();
const router = useRouter();
const search = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("event") || "");
// Kept with the event they're for, so changing events never shows the last one's.
const [loaded, setLoaded] = useState<{ event: CorrectionEvent; registrations: CorrectionRow[] } | null>(null);
const [formOf, setFormOf] = useState<{ eventId: string; form: AttendeeFormDef } | null>(null);
const data = loaded && loaded.event.id === eventId ? loaded : null;
const form = formOf?.eventId === eventId ? formOf.form : null;
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<Filter>("attention");
const [query, setQuery] = useState("");
const [sendMissing, setSendMissing] = useState(false);
const [message, setMessage] = useDismissingState<{ ok: boolean; text: string } | null>(null);
const isAdmin = user?.role === "admin";
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Every event — past and closed ones too, newest first.
useEffect(() => {
if (!token || !isAdmin) return;
fetchManagedEvents<Event>(token, { past: true, inactive: true })
.then(evs => setEvents([...(evs || [])].sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())))
.catch(() => setEvents([]));
}, [token, isAdmin]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
setLoaded(await (apiV2Data("get", "/corrections/events/{id}", { params: { id: eventId }, authToken: token }) as Promise<{ event: CorrectionEvent; registrations: CorrectionRow[] }>));
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load the event." });
}
}, [token, eventId, setMessage]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!eventId) return;
fetchEvent<Event>(eventId, { authToken: token || undefined })
.then(ev => setFormOf({ eventId, form: ev?.form }))
.catch(() => setFormOf({ eventId, form: null }));
}, [eventId, token]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return (data?.registrations || []).filter(r => {
if (filter === "attention" && !needsAttention(r)) return false;
if (filter === "unpaid" && !(r.balance > 0)) return false;
if (filter === "not_checked_in" && !notCheckedIn(r)) return false;
if (!q) return true;
return [r.user.name, r.user.email, r.user.phoneNumber, r.id].some(v => String(v || "").toLowerCase().includes(q));
});
}, [data, filter, query]);
const counts = useMemo(() => {
const regs = data?.registrations || [];
return {
attention: regs.filter(needsAttention).length,
unpaid: regs.filter(r => r.balance > 0).length,
not_checked_in: regs.filter(notCheckedIn).length,
all: regs.length,
missing: regs.filter(r => r.missingTickets).length,
};
}, [data]);
const issueMissing = async () => {
if (!token || !eventId) return;
setBusy(true);
try {
const res = await (apiV2Data("post", "/corrections/events/{id}/issue-missing", { params: { id: eventId }, authToken: token, body: { send: sendMissing } }) as Promise<{ registrations: number; tickets: number; failed: { name: string; message: string }[] }>);
setMessage({
ok: res.failed.length === 0,
text: `${res.tickets} ticket(s) issued for ${res.registrations} registration(s).${res.failed.length ? ` Not fixed: ${res.failed.map(f => `${f.name} (${f.message})`).join("; ")}` : ""}`,
});
await load();
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "That didn't work." });
} finally {
setBusy(false);
}
};
const onMessage = useCallback((ok: boolean, text: string) => setMessage({ ok, text }), [setMessage]);
if (!loading && user && !isAdmin) {
return <div className="max-w-4xl mx-auto p-6"><div className="p-3 border rounded bg-yellow-50 text-sm">Corrections are for admins only.</div></div>;
}
const FILTERS: { key: Filter; label: string }[] = [
{ key: "attention", label: "Needs attention" },
{ key: "unpaid", label: "Owes money" },
{ key: "not_checked_in", label: "Not checked in" },
{ key: "all", label: "All" },
];
return (
<div className="max-w-4xl mx-auto w-full p-4 sm:p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wrench className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard/admin" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Corrections</h1>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
Put registrations right after the fact: issue tickets held back by an attendee form or still unpaid, make a balance free, and check people in after the event. Everything is kept in the audit log.
</p>
<EventPicker className="w-full sm:w-96 mb-4" events={events} value={eventId} onChange={id => { setEventId(id); setFilter("attention"); }} placeholder="Choose an event (past events too)" />
{message && (
<div role="status" className={`p-3 mb-3 border rounded text-sm ${message.ok ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>
)}
{data && (
<>
<div className="flex flex-wrap items-center gap-2 mb-3 text-xs">
{data.event.closed && <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">Closed (cashed up) — money can&rsquo;t change; tickets and check-ins can</span>}
{data.event.formRequired && <span className="px-2 py-0.5 rounded-full bg-amber-50 text-amber-700">Required attendee form</span>}
{new Date(data.event.endDate) < new Date() && <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">Event is over</span>}
</div>
{counts.missing > 0 && (
<div className="border border-red-200 bg-red-50 rounded-xl p-4 mb-4">
<div className="flex items-start gap-2 text-sm text-red-800">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<strong>{counts.missing} paid registration{counts.missing === 1 ? " is" : "s are"} missing tickets.</strong> Issue them all now — attendee forms still outstanding are left for later.
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3">
<button type="button" disabled={busy} onClick={issueMissing} className="px-3 py-1.5 text-sm rounded bg-red-600 text-white disabled:opacity-50">
{busy ? "Issuing…" : `Issue all missing tickets (${counts.missing})`}
</button>
<label className="flex items-center gap-2 text-xs text-red-900">
<input type="checkbox" checked={sendMissing} onChange={e => setSendMissing(e.target.checked)} />
Send them to the attendees
</label>
</div>
</div>
)}
<div className="flex flex-wrap gap-2 mb-3">
{FILTERS.map(f => (
<button key={f.key} type="button" onClick={() => setFilter(f.key)} className={`px-3 py-1.5 text-xs rounded-full border ${filter === f.key ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
{f.label} ({counts[f.key]})
</button>
))}
<input className="ml-auto border rounded px-3 py-1.5 text-sm w-full sm:w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} aria-label="Search registrations" />
</div>
<div className="space-y-3">
{rows.map(r => (
<RegistrationCard key={r.id} row={r} event={data.event} form={form} token={token!} onChanged={load} onMessage={onMessage} />
))}
{rows.length === 0 && (
<div className="text-sm text-gray-500 border rounded-xl p-6 text-center bg-white">
{filter === "attention" ? "Nothing needs putting right at this event." : "No registrations match."}
</div>
)}
</div>
</>
)}
</div>
);
}
export default function CorrectionsPage() {
return (
<Suspense fallback={null}>
<CorrectionsContent />
</Suspense>
);
}
@@ -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);
+5 -4
View File
@@ -3,13 +3,12 @@
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 {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart, Layers,
Baby, HandHeart, Layers, Wrench,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -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)}`;
@@ -48,6 +48,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/corrections", label: "Corrections", description: "Missing tickets, free tickets, forms and late check-ins", icon: Wrench },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
{ href: "/dashboard/admin/data-requests", label: "Data requests", description: "POPIA: people asking to correct or delete their information", icon: Inbox },
@@ -93,9 +94,9 @@ export default function AdminDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [dash, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
(apiV2Data("get", "/stats/admin", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
@@ -4,7 +4,8 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiError, resolveToApiOrigin } from "@/lib/api";
import { apiV2Raw, apiV2Data } from "@/lib/apiV2";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
@@ -164,7 +165,7 @@ function SiteSettingsPageInner() {
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>)
.then((s) => {
setOrgName(s.org_name || "");
setOrgTagline(s.org_tagline || "");
@@ -212,7 +213,7 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e) {
@@ -251,8 +252,8 @@ function SiteSettingsPageInner() {
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
@@ -283,8 +284,8 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
@@ -325,7 +326,7 @@ function SiteSettingsPageInner() {
smtp_user: smtpUser.trim(),
};
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
@@ -351,8 +352,7 @@ function SiteSettingsPageInner() {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
await apiFetch("/api/settings/test-smtp", {
method: "POST",
await apiV2Data("post", "/settings/test-smtp", {
authToken: token,
body: {
host: smtpHost.trim(),
@@ -884,7 +884,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/config", { authToken: token }) as Promise<ConfigResponse>);
setCfg(res);
} catch {
// network error — leave cfg null, tab shows loading state
@@ -898,7 +898,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/status", { authToken: token }) as Promise<StatusResponse>);
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch {
@@ -921,7 +921,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/qr", { authToken: token }) as Promise<{ qr?: string }>);
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
@@ -945,7 +945,9 @@ function WhatsAppTab({ active }: { active: boolean }) {
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
const res = action === "delete-instance"
? await apiV2Raw<{ message?: string }>("delete", "/whatsapp/instance", { authToken: token })
: await apiV2Raw<{ message?: string }>("post", `/whatsapp/${action}`, { authToken: token, body: body || {} });
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
@@ -970,7 +972,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: inputToken.trim(), instanceId: "" } });
setInputToken("");
await fetchConfig();
} catch (e) {
@@ -988,7 +990,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "", instanceId: inputInstanceId.trim() } });
setInputInstanceId("");
await fetchConfig();
} catch (e) {
@@ -1002,7 +1004,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
const res = await (apiV2Data("post", "/whatsapp/instance", { authToken: token, body: {} }) as Promise<{ message?: string }>);
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e) {
@@ -1023,7 +1025,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "_clear_", instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "_clear_", instanceId: "" } });
} catch {}
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
@@ -1257,9 +1259,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
await apiV2Data("put", "/whatsapp/config", {
authToken: token,
body: { token: inputToken.trim() || undefined, instanceId: inputInstanceId.trim() || undefined },
});
setActionMsg({ type: "ok", text: "Credentials updated." });
+21 -17
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
@@ -13,6 +12,7 @@ import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
interface UserItem {
id: string;
@@ -91,7 +91,7 @@ export default function AdminUsersPage() {
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
if (roleFilter) qs.set("role", roleFilter);
if (activeFilter !== "") qs.set("isActive", activeFilter);
return `/api/users?${qs.toString()}`;
return Object.fromEntries(qs);
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
@@ -99,8 +99,7 @@ export default function AdminUsersPage() {
setError(null);
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: buildQuery(p, ps), authToken: token }) as Promise<Paginated<UserItem>>);
setUsers(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
@@ -131,13 +130,16 @@ export default function AdminUsersPage() {
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<{ id?: string }>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
// An admin adding someone is a sign-up on their behalf (they agreed to the terms for them).
const { data: made } = await apiV2("post", "/auth/signup", {
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, acceptTerms: true, ...(cPhone ? { phone: cPhone } : {}) },
});
const created = { id: made.user.id };
if (cRole && cRole !== "user" && created?.id) {
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
method: "PUT", authToken: token!, body: { role: cRole },
await apiV2Data("patch", "/users/{id}", {
params: { id: created.id },
authToken: token,
body: { role: cRole },
});
}
resetCreateForm();
@@ -156,7 +158,7 @@ export default function AdminUsersPage() {
const [scope, setScope] = useState<{ id: string; name: string }[] | null>(null);
const startEdit = (u: UserItem) => {
setEditingId(u.id); setEditData({ ...u, password: "" }); setScope(null);
apiFetch<{ scopedCampuses?: { id: string; name: string }[] }>(`/api/users/${encodeURIComponent(u.id)}`, { authToken: token! })
(apiV2Data("get", "/users/{id}", { params: { id: u.id }, authToken: token }) as Promise<{ scopedCampuses?: { id: string; name: string }[] }>)
.then(full => setScope(full.scopedCampuses || []))
.catch(() => setScope(null));
};
@@ -181,8 +183,10 @@ export default function AdminUsersPage() {
payload.password = editData.password.trim();
}
if (campusInfo.enabled && canBeScoped && scope) payload.scopedCampusIds = scope.map(c => c.id);
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
await apiV2Data("patch", "/users/{id}", {
params: { id: editingId },
authToken: token,
body: payload,
});
setEditingId(null);
setEditData({});
@@ -197,7 +201,7 @@ export default function AdminUsersPage() {
const revokeUserSessions = async (id: string, name: string) => {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/sessions/revoke", { params: { id: id }, authToken: token, body: {} });
} catch (e) {
setError(errorMessage(e) || "Failed to revoke sessions");
}
@@ -212,7 +216,7 @@ export default function AdminUsersPage() {
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
const res = await (apiV2Data("post", "/admin/impersonate/{userId}", { params: { userId: impTarget.id }, authToken: token, body: { reason: impReason } }) as Promise<ImpersonationStart>);
beginImpersonation(res);
} catch (e) {
setImpError(errorMessage(e) || "Couldn't start impersonation");
@@ -228,7 +232,7 @@ export default function AdminUsersPage() {
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
if (!confirm(message)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await apiV2Data("patch", "/users/{id}", { params: { id: id }, authToken: token, body: { twoFactorEnabled: false } });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to reset two-factor authentication");
@@ -238,7 +242,7 @@ export default function AdminUsersPage() {
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await apiV2Data("delete", "/users/{id}", { params: { id: id }, authToken: token });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to deactivate user");
@@ -248,7 +252,7 @@ export default function AdminUsersPage() {
const deleteUserData = async (id: string, name: string) => {
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/anonymize", { params: { id: id }, authToken: token, body: {} });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to delete user data");
@@ -2,7 +2,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
@@ -13,6 +13,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, Ticket as TicketRecord } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -82,10 +83,10 @@ function EventTicketsContent() {
setLoading(true);
setError(null);
try {
const list = await apiFetch<TicketRecord[]>(
`/api/tickets/event/${encodeURIComponent(eventId)}`,
{ authToken: token }
);
const list = await (apiV2Data("get", "/events/{id}/tickets", {
params: { id: eventId },
authToken: token,
}) as Promise<TicketRecord[]>);
setTickets(list);
} catch (e) {
setError(errorMessage(e) || "Failed to load tickets");
+29 -23
View File
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -14,6 +14,7 @@ import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard } from "@/components/kids/RoomBoard";
import { StickerPanel } from "@/components/kids/StickerPanel";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { apiV2Data } from "@/lib/apiV2";
import {
FAMILY_QR_PREFIX, QR_PREFIX, deviceId, loadOffline, offlineCheckIn, saveOffline, stickerAlert, suggestRoom,
type CheckInResult, type KidsBoard, type KidsFamily, type OfflineState,
@@ -64,19 +65,18 @@ export default function KidsDeskPage() {
const [collectedBy, setCollectedBy] = useState("");
const [wrongCode, setWrongCode] = useState<string | null>(null);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const loadBoard = useCallback(async (log = false) => {
if (!token || !eventId || working === "offline") return;
try {
const data = await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token });
const data = await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>);
setBoard(data);
setBoardError(null);
} catch (e) {
setBoard(null);
setBoardError(errorMessage(e) || "Could not load the desk");
}
}, [token, eventId, qs, working]);
}, [token, eventId, sessionId, working]);
useEffect(() => {
setBoard(null);
@@ -145,7 +145,7 @@ export default function KidsDeskPage() {
return;
}
try {
setResults(await apiFetch<KidsFamily[]>(`/api/kids/events/${eventId}/families?q=${encodeURIComponent(term)}`, { authToken: token }));
setResults(await (apiV2Data("get", "/events/{id}/kids/families", { params: { id: eventId }, query: { q: term }, authToken: token }) as Promise<KidsFamily[]>));
} catch (err) {
fail(err, "Search failed");
}
@@ -163,7 +163,7 @@ export default function KidsDeskPage() {
if (!t.toUpperCase().startsWith(FAMILY_QR_PREFIX)) { setMsg({ type: "err", text: "That isn't a family or pickup QR code." }); return; }
if (working === "offline") { setMsg({ type: "err", text: "Family QR codes need a connection — search by name or phone instead." }); return; }
try {
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/qr/${encodeURIComponent(t.slice(FAMILY_QR_PREFIX.length))}`, { authToken: token }));
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/qr/{token}", { params: { id: eventId, token: t.slice(FAMILY_QR_PREFIX.length) }, authToken: token }) as Promise<KidsFamily>));
} catch (err) {
fail(err, "Family not found");
}
@@ -173,10 +173,11 @@ export default function KidsDeskPage() {
if (!newFamily) return;
setBusy(true);
try {
const f = await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families`, {
method: "POST", authToken: token,
const f = await (apiV2Data("post", "/events/{id}/kids/families", {
params: { id: eventId },
authToken: token,
body: { name: newFamily.name, parent: { name: newFamily.parentName, phone: newFamily.phone, email: newFamily.email }, children: [child] },
});
}) as Promise<KidsFamily>);
setNewFamily(null);
chooseFamily(f);
setMsg({ type: "ok", text: `${f.name} added.` });
@@ -191,8 +192,8 @@ export default function KidsDeskPage() {
if (!family) return;
setBusy(true);
try {
await apiFetch(`/api/kids/events/${eventId}/families/${family.id}/children`, { method: "POST", authToken: token, body: child });
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/${family.id}`, { authToken: token }));
await apiV2Data("post", "/events/{id}/kids/families/{familyId}/children", { params: { id: eventId, familyId: family.id }, authToken: token, body: child });
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/{familyId}", { params: { id: eventId, familyId: family.id }, authToken: token }) as Promise<KidsFamily>));
setMsg({ type: "ok", text: `${child.firstName} added.` });
} catch (err) {
fail(err, "Could not add the child");
@@ -225,9 +226,11 @@ export default function KidsDeskPage() {
}
setBusy(true);
try {
const r = await apiFetch<CheckInResult>(`/api/kids/events/${eventId}/check-in`, {
method: "POST", authToken: token, body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
});
const r = await (apiV2Data("post", "/events/{id}/kids/check-in", {
params: { id: eventId },
authToken: token,
body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
}) as Promise<CheckInResult>);
setResult(r);
loadBoard();
} catch (err) {
@@ -268,7 +271,7 @@ export default function KidsDeskPage() {
}
} else {
try {
l = await apiFetch<CodeLookup>(`/api/kids/events/${eventId}/code/${encodeURIComponent(raw.trim())}${qs}`, { authToken: token });
l = await (apiV2Data("get", "/events/{id}/kids/code/{code}", { params: { id: eventId, code: raw.trim() }, query: { sessionId: sessionId || undefined }, authToken: token }) as Promise<CodeLookup>);
} catch (err) {
fail(err, "Lookup failed");
return;
@@ -276,7 +279,7 @@ export default function KidsDeskPage() {
if (!l.found) {
// Record the wrong code (alerts the rooms) through the check-out call.
try {
await apiFetch(`/api/kids/events/${eventId}/check-out`, { method: "POST", authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
await apiV2Data("post", "/events/{id}/kids/check-out", { params: { id: eventId }, authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
} catch (err) {
setWrongCode(errorMessage(err) || "Wrong code");
}
@@ -306,9 +309,11 @@ export default function KidsDeskPage() {
}
setBusy(true);
try {
const r = await apiFetch<{ checkedOut: { name: string }[] }>(`/api/kids/events/${eventId}/check-out`, {
method: "POST", authToken: token, body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
});
const r = await (apiV2Data("post", "/events/{id}/kids/check-out", {
params: { id: eventId },
authToken: token,
body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
}) as Promise<{ checkedOut: { name: string }[] }>);
setMsg({ type: "ok", text: `Collected: ${r.checkedOut.map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
@@ -325,7 +330,7 @@ export default function KidsDeskPage() {
const getReady = async () => {
setBusy(true);
try {
const snapshot = await apiFetch<OfflineState["snapshot"]>(`/api/kids/events/${eventId}/offline?deviceId=${encodeURIComponent(deviceId())}${sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` : ""}`, { authToken: token });
const snapshot = await (apiV2Data("get", "/events/{id}/kids/offline", { params: { id: eventId }, query: { deviceId: deviceId(), sessionId: sessionId || undefined }, authToken: token }) as Promise<OfflineState["snapshot"]>);
const prev = loadOffline(eventId);
const sameOccasion = prev?.snapshot.occasion === snapshot.occasion;
const state: OfflineState = {
@@ -348,10 +353,11 @@ export default function KidsDeskPage() {
if (!offline) return;
setBusy(true);
try {
const r = await apiFetch<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>(`/api/kids/events/${eventId}/sync`, {
method: "POST", authToken: token,
const r = await (apiV2Data("post", "/events/{id}/kids/sync", {
params: { id: eventId },
authToken: token,
body: { deviceId: deviceId(), occasion: offline.snapshot.occasion, checkIns: offline.checkIns, checkOuts: offline.checkOuts },
});
}) as Promise<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>);
const next: OfflineState = { ...offline, checkIns: [], checkOuts: [] };
saveOffline(eventId, next);
setOffline(next);
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard, RoomStatusBadges } from "@/components/kids/RoomBoard";
import type { KidsBoard } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
const ROOM_KEY = "hope_events_kids_room";
@@ -26,17 +26,16 @@ export default function KidsRoomsPage() {
useEffect(() => { try { setRoomId(localStorage.getItem(ROOM_KEY) || ""); } catch { /* ignore */ } }, []);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const load = useCallback(async (log = false) => {
if (!token || !eventId) return;
try {
setBoard(await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token }));
setBoard(await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>));
setError(null);
} catch (e) {
setBoard(null);
setError(errorMessage(e) || "Could not load the rooms");
}
}, [token, eventId, qs]);
}, [token, eventId, sessionId]);
useEffect(() => { load(true); }, [load]);
useVisiblePolling(() => { load(); }, 5000, !!token && !!eventId);
@@ -49,7 +48,7 @@ export default function KidsRoomsPage() {
const onDuty = !!room && !!user && room.leaderIds.includes(user.id);
const duty = async (on: boolean) => {
try {
await apiFetch(`/api/kids/events/${eventId}/duty`, { method: "POST", authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
await apiV2Data("post", "/events/{id}/kids/duty", { params: { id: eventId }, authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
load();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not change duty" });
@@ -2,10 +2,10 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type Member = {
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
@@ -30,7 +30,7 @@ export default function MyGroupsPage() {
useEffect(() => {
if (!token) return;
apiFetch<{ medicalShown: boolean; groups: MyGroup[] }>("/api/allocations/my-groups", { authToken: token })
(apiV2Data("get", "/me/allocation-groups", { authToken: token }) as Promise<{ medicalShown: boolean; groups: MyGroup[] }>)
.then(setData).catch(e => setError(e?.message || "Failed to load your groups"));
}, [token]);
+2 -2
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
@@ -13,6 +12,7 @@ import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -52,7 +52,7 @@ export default function StaffDashboardPage() {
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
const data = await (apiV2Data("get", "/stats/staff", { authToken: token }) as Promise<DashboardStats>);
setStats(data.scanStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch {
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
@@ -14,7 +14,7 @@ import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { listSections } from "@/lib/eventSetup";
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
@@ -330,7 +330,7 @@ export default function TicketScanningPage() {
const loadRecent = useCallback(async () => {
if (!token) return;
try {
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
const list = await (apiV2Data("get", "/tickets/scans/recent", { query: { limit: "10" }, authToken: token }) as Promise<RecentScan[]>);
setRecentScans(list);
} catch {}
}, [token]);
@@ -338,7 +338,7 @@ export default function TicketScanningPage() {
if (!token) return;
try {
setLoadingStats(true);
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
const s = await (apiV2Data("get", "/tickets/scans/stats", { authToken: token }) as Promise<ScanStats>);
setStats(s);
} catch {} finally {
setLoadingStats(false);
@@ -2,7 +2,6 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import type { RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
export default function StaffVolunteersPage() {
@@ -22,7 +22,7 @@ export default function StaffVolunteersPage() {
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
(apiV2Data("get", "/volunteering/events", { authToken: token }) as Promise<RosterEvent[]>)
.then(list => {
setEvents(list);
const now = Date.now();
@@ -4,13 +4,14 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
@@ -107,7 +108,7 @@ export default function AllocationsPage() {
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const b = await (apiV2Data("get", "/events/{id}/allocations", { params: { id: eventId }, authToken: token }) as Promise<Board>);
setBoard(b);
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
} catch (e) {
@@ -121,8 +122,9 @@ export default function AllocationsPage() {
const membersOf = (groupId: string) => (board?.attendees || []).filter(p => groupOf.get(p.id) === groupId);
const unassigned = (board?.attendees || []).filter(p => !groupOf.has(p.id));
const call = async <T,>(path: string, method: string, body?: unknown): Promise<T> =>
apiFetch<T>(path, { method, authToken: token!, ...(body !== undefined ? { body } : {}) });
// Paths are API v2's.
const call = async <T,>(path: string, method: "get" | "post" | "put" | "patch" | "delete", body?: unknown): Promise<T> =>
apiV2Raw<T>(method, path, { authToken: token, ...(body !== undefined ? { body } : {}) });
const onDragEnd = async (e: DragEndEvent) => {
if (!current || !e.over) return;
@@ -139,7 +141,7 @@ export default function AllocationsPage() {
}),
}));
try {
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
await call(`/allocation-sets/${current.id}/assignments`, "put", { attendeeId, groupId });
} catch (err) {
setError(errorMessage(err) || "Couldn't move them");
load();
@@ -160,21 +162,21 @@ export default function AllocationsPage() {
};
const addSet = () => run(async () => {
const s = await call<{ id: string }>("/api/allocations/sets", "POST", { eventId, name: newSetName || "Rooms" });
const s = await call<{ id: string }>("/allocation-sets", "post", { eventId, name: newSetName || "Rooms" });
setNewSetName("");
setSetId(s.id);
});
const saveGroup = () => groupForm && current && run(async () => {
const body = { name: groupForm.name, capacity: groupForm.capacity || null, gender: groupForm.gender || null, leaderUserId: groupForm.leaderUserId || null };
if (groupForm.id) await call(`/api/allocations/groups/${groupForm.id}`, "PUT", body);
else await call(`/api/allocations/sets/${current.id}/groups`, "POST", body);
if (groupForm.id) await call(`/allocation-groups/${groupForm.id}`, "patch", body);
else await call(`/allocation-sets/${current.id}/groups`, "post", body);
setGroupForm(null);
});
const printLists = async (master: boolean, medical: boolean) => {
if (!current || !board) return;
try {
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
const data = await apiV2Raw<SetLists>("get", `/allocation-sets/${current.id}/lists`, { query: { medical: medical ? "true" : undefined }, authToken: token });
const w = window.open("", "_blank");
if (!w) return;
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
@@ -249,19 +251,19 @@ export default function AllocationsPage() {
<>
<div className="flex flex-wrap items-center gap-2 border rounded-xl p-3 bg-white">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/api/allocations/sets/${current.id}`, "PUT", { showOnTicket: e.target.checked }); })} />
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/allocation-sets/${current.id}`, "patch", { showOnTicket: e.target.checked }); })} />
Show {current.name.toLowerCase()} on tickets and in My Events
</label>
<div className="ml-auto flex flex-wrap gap-2">
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/auto`, "post", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/auto`, "post", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
<button onClick={() => printLists(false, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Group lists</button>
<button onClick={() => printLists(true, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Master list</button>
{board.event.requiresParentalConsent && (
<button onClick={() => printLists(false, true)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50" title="Includes medical details from consent forms. Viewing is recorded in the audit log."><Printer className="w-4 h-4" /> Lists with medical info</button>
)}
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/assignments`, "DELETE")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
<button disabled={busy} onClick={() => run(async () => { await call(`/api/allocations/sets/${current.id}`, "DELETE"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/allocation-sets/${current.id}/assignments`, "delete")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
<button disabled={busy} onClick={() => run(async () => { await call(`/allocation-sets/${current.id}`, "delete"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
</div>
</div>
@@ -304,7 +306,7 @@ export default function AllocationsPage() {
<Column key={g.id} id={g.id} title={g.name} subtitle={subtitle} full={full} actions={
<div className="flex gap-1 shrink-0">
<button aria-label={`Edit ${g.name}`} onClick={() => setGroupForm({ id: g.id, name: g.name, capacity: g.capacity ? String(g.capacity) : "", gender: g.gender || "", leaderUserId: g.leaderUserId || "" })} className="p-1 rounded hover:bg-gray-100"><Pencil className="w-3.5 h-3.5" /></button>
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/api/allocations/groups/${g.id}`, "DELETE"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/allocation-groups/${g.id}`, "delete"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
}>
{members.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -19,6 +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 { AttendeeFormModal, formsStillNeeded } from "@/components/shared/AttendeeFormModal";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -198,12 +199,37 @@ export default function AtTheDoorPage() {
const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
// A required attendee form holds the tickets back: it pops up to fill in now, or "Fill in
// later" (tickets are issued anyway and the attendee gets a link).
const [formPrompt, setFormPrompt] = useState<{ registration: Registration; needed: number } | null>(null);
const loadFull = (id: string) => apiV2Data("get", "/registrations/{id}/full", { params: { id }, authToken: token }) as Promise<Registration>;
const promptFormFor = (full: Registration) => {
const needed = formsStillNeeded(doorEvent?.form, full);
if (needed > 0 && full.status !== "cancelled") setFormPrompt({ registration: full, needed });
return needed;
};
const handleRegistrationCreated = (registration: Registration) => {
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
if ((registration.formsNeeded || 0) > 0) setFormPrompt({ registration, needed: registration.formsNeeded || 0 });
};
const handleRegistrationSelected = (registration: Registration) => {
setActiveRegistration(registration);
// The search list doesn't carry the forms, so look at the whole registration.
loadFull(registration.id).then(promptFormFor).catch(() => {});
};
const handleFormDone = async ({ later, ticketsIssued, sentTo }: { later: boolean; ticketsIssued: number; sentTo?: string[] }) => {
const reg = formPrompt?.registration;
setFormPrompt(null);
const issued = ticketsIssued > 0 ? " Tickets issued." : "";
setInfo(later
? `Form left for later${sentTo && sentTo.length ? ` — a link was sent by ${sentTo.join(" and ")}` : ""}.${issued}`
: `Attendee form saved.${issued}`);
if (reg) {
try { setActiveRegistration(await loadFull(reg.id)); } catch {}
}
};
useEffect(() => {
@@ -404,9 +430,14 @@ export default function AtTheDoorPage() {
// Tickets are generated and emailed/WhatsApped automatically server-side
// once a registration reaches "paid" (see paymentController) — no need to
// fetch/print them here, just move straight to checking the attendee in.
// Unless a required attendee form holds them back: that pops up first.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
if (reg && promptFormFor(reg) > 0) {
setInfo("Payment recorded — fill in the attendee form (or choose Fill in later) to issue the tickets.");
return;
}
setInfo("Payment recorded — tickets sent. Ready to check in.");
};
const handleDonation = (user?: UserSummary) => {
@@ -499,7 +530,12 @@ export default function AtTheDoorPage() {
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
<DoorCheckInPanel
token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo}
onFillForm={activeRegistration ? () => loadFull(activeRegistration.id).then(full => {
if (promptFormFor(full) === 0) setError("No attendee form is outstanding. If the tickets are still missing, an admin can issue them in Corrections.");
}).catch(e => setError(errorMessage(e) || "Couldn't load the registration")) : undefined}
/>
)}
{mode === "tickets" && (
@@ -550,6 +586,17 @@ export default function AtTheDoorPage() {
seed={newAttendeeSeed}
onConfirm={handleNewAttendeeConfirm}
/>
{formPrompt && (
<AttendeeFormModal
key={formPrompt.registration.id}
registrationId={formPrompt.registration.id}
form={doorEvent?.form}
needed={formPrompt.needed}
token={token}
attendeeName={formPrompt.registration.user?.name}
onDone={handleFormDone}
/>
)}
</div>
);
}
@@ -885,7 +932,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
(async () => {
try {
setLoading(true);
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
const res = await (apiV2Data("get", "/events/{id}/tickets", { params: { id: eventId }, authToken: token }) as Promise<EventTicketsResponse>);
setAllTickets(ticketsFrom(res));
} catch {
setAllTickets([]);
@@ -919,7 +966,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([]);
@@ -1027,12 +1074,14 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo, onFillForm }: {
token: string | null;
eventId: string;
registration: Registration | null;
setError: (e: string | null) => void;
setInfo: (s: string | null) => void;
/** No tickets yet: open the attendee form that holds them back. */
onFillForm?: () => void;
}) {
const [ticketsForEvent, setTicketsForEvent] = useState<TicketRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -1043,7 +1092,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 {
@@ -1053,7 +1102,8 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
}
}, [token, eventId]);
useEffect(() => { load(); }, [load]);
// Again when the registration changes: its tickets may have just been issued.
useEffect(() => { load(); }, [load, registration]);
if (!registration) {
return (
@@ -1117,9 +1167,18 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
registration.status === "paid" && onFillForm ? (
<div className="text-sm border border-amber-200 bg-amber-50 rounded-lg p-3 text-amber-900">
No tickets yet. If the event has an attendee form, it&rsquo;s holding them back.
<button type="button" onClick={onFillForm} className="block mt-2 px-3 py-1.5 rounded bg-amber-600 text-white text-xs font-medium">
Fill in the attendee form
</button>
</div>
) : (
<div className="text-sm text-gray-500">
{registration.status === "paid" ? "No Main Tickets on this registration" : "No tickets yet — they're issued once it's paid."}
</div>
)
)}
<div className="space-y-4">
@@ -1536,10 +1595,9 @@ function SendTicketsModal({ open, onClose, token, registration }: {
}
setSending(true);
try {
await apiFetch("/api/tickets/send-to", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/tickets/send", {
authToken: token,
body: {
registrationId: registration?.id,
channel,
overrideEmail: email.trim() || undefined,
@@ -4,13 +4,14 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from "reac
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { API_BASE, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
@@ -57,7 +58,7 @@ function AttendancePageInner() {
if (!token || !eventId) return;
setBusy(true);
try {
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
setReg(await (apiV2Data("get", "/events/{id}/attendance", { params: { id: eventId }, authToken: token }) as Promise<AttendanceRegister>));
} catch (e) {
setError(errorMessage(e) || "Failed to load the register");
} finally {
@@ -93,7 +94,7 @@ function AttendancePageInner() {
try {
// Picked sessions have a ticket per session.
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
await apiV2Data("post", "/events/{id}/attendance", { params: { id: eventId }, authToken: token, body: { ticketId, sessionId, present } });
const col = reg?.columns.find(c => c.id === sessionId);
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
await load();
@@ -108,7 +109,7 @@ function AttendancePageInner() {
if (!token) return;
setBusy(true);
try {
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
const r = await apiV2Raw<Record<string, number>>("post", path, { authToken: token, body });
setInfo(done(r));
await load();
} catch (e) {
@@ -173,8 +174,8 @@ function AttendancePageInner() {
</label>
)}
<div className="ml-auto flex flex-wrap gap-2">
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/v2/events/${eventId}/attendance/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
</div>
</div>
@@ -191,9 +192,9 @@ function AttendancePageInner() {
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
<Award className="w-4 h-4 text-brand-600" />
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
<button type="button" disabled={busy} onClick={() => post(`/events/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/events/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/v2/events/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
</div>
)}
@@ -248,12 +249,12 @@ function AttendancePageInner() {
{r.certificate && !r.certificate.revokedAt ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700">{r.certificate.code}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/v2/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
</div>
) : canManage ? (
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/events/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
</button>
) : <span className="text-xs text-gray-400">—</span>}
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -446,7 +446,7 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: { search: userQuery.trim(), limit: "8" }, authToken: token }) as Promise<{ data: { id: string; name: string; email: string }[] }>);
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -280,7 +280,7 @@ function EmailAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "email" }, authToken: token }) as Promise<{jobs: ScheduledJob[]}>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch {
// ignore here; surfaces via UI when tab open
@@ -315,7 +315,7 @@ function EmailAttendeesPageInner() {
body.html = '';
}
}
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body });
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
setInfo('Scheduled email updated.');
setEditing(null);
loadScheduled();
@@ -329,7 +329,7 @@ function EmailAttendeesPageInner() {
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError('Not authenticated'); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
setInfo('Scheduled email removed.');
loadScheduled();
} catch (e) {
@@ -432,7 +432,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -473,7 +473,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
@@ -670,7 +670,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) {
setInfo('Email scheduled. It will be sent around the specified time.');
} else {
@@ -779,7 +779,7 @@ Jane Doe <jane@example.com>
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined, kind: broadcastKind };
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
const res = await (apiV2Data("post", "/broadcasts/email/preview", { authToken: token, body }) as Promise<EmailSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -796,7 +796,7 @@ Jane Doe <jane@example.com>
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/send", { authToken: token, body: payload }) as Promise<EmailSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ''}`);
// Reset broadcast form
resetBroadcastForm();
@@ -814,7 +814,7 @@ Jane Doe <jane@example.com>
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/schedule", { authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
// Reset broadcast form
resetBroadcastForm();
@@ -1085,7 +1085,7 @@ With gratitude`);
jobs.push(payload);
}
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
const res = await (apiV2Data("post", "/automations/schedule", { authToken: token, body: { eventId: autoEventId, jobs } }) as Promise<{ message?: string }>);
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
// Reset to defaults (keep event selection)
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -689,7 +689,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
const res = await (apiV2Data("get", "/users", { query: { search: search.trim(), limit: "10" }, authToken: token }) as Promise<Paginated<NotifyUser>>);
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
@@ -1694,7 +1694,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
uploadButton={(onUploaded) => <UploadImageButton onUploaded={onUploaded} label="Upload" />}
onPreview={async () => {
if (!token) return;
openBlob(await fetchApiFile(API_BASE, token, "/api/attendance/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
openBlob(await fetchApiFile(API_BASE, token, "/api/v2/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
}}
/>
)}
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
@@ -34,13 +34,13 @@ export default function KidsSetupPage() {
useEffect(() => {
if (!token) return;
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
(apiV2Data("get", "/kids/setup-events", { authToken: token }) as Promise<SetupEvent[]>).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
const data = await (apiV2Data("get", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>);
setS(data);
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
@@ -64,16 +64,16 @@ export default function KidsSetupPage() {
setBusy(false);
}
};
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiV2Data("put", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token, body }), ok);
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
const addRoom = () => s?.event.kidsRoomSetId && run(
() => apiFetch(`/api/allocations/sets/${s.event.kidsRoomSetId}/groups`, { method: "POST", authToken: token, body: roomBody(newRoom) }),
() => apiV2Data("post", "/allocation-sets/{id}/groups", { params: { id: s.event.kidsRoomSetId! }, authToken: token, body: roomBody(newRoom) }),
"Room added.",
).then(ok => { if (ok) setNewRoom({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" }); });
const saveRoom = (id: string) => run(() => apiFetch(`/api/allocations/groups/${id}`, { method: "PUT", authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiFetch(`/api/allocations/groups/${r.id}`, { method: "DELETE", authToken: token }), "Room deleted.");
const saveRoom = (id: string) => run(() => apiV2Data("patch", "/allocation-groups/{id}", { params: { id: id }, authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiV2Data("delete", "/allocation-groups/{id}", { params: { id: r.id }, authToken: token }), "Room deleted.");
const roomRow = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }, set: (v: typeof d) => void) => (
<>
@@ -167,6 +167,21 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
}
};
// The tickets stop waiting for the form (issued now if it's paid); the attendee gets a link.
const later = async () => {
if (!token) return;
try {
setSubmitting(true);
setError(null); setInfo(null);
const res = await (apiV2Data("post", "/registrations/{id}/form-later", { params: { id: registration.id }, authToken: token, body: {} }) as Promise<{ ticketsIssued: number; sentTo: string[] }>);
setInfo(`Form left for later${res?.sentTo?.length ? ` — a link was sent by ${res.sentTo.join(' and ')}` : ''}.${res?.ticketsIssued ? ' Tickets issued.' : ''}`);
} catch (e) {
setError(errorMessage(e) || 'Failed to leave the form for later');
} finally {
setSubmitting(false);
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="mt-6 border rounded p-3 bg-gray-50">
@@ -212,7 +227,10 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
{form.isRequired && <button className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50" disabled={submitting} onClick={later}>Fill in later</button>}
</div>
</div>
);
}
@@ -18,6 +18,7 @@ import type { Event, EventOption, PublicBundle, Registration, RegistrationOption
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
import { AttendeeFormModal, type AttendeeFormDef } from "@/components/shared/AttendeeFormModal";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -91,6 +92,15 @@ export default function ManualRegistrationPage() {
const [paySubmitting, setPaySubmitting] = useState(false);
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
const [payError, setPayError] = useDismissingState<string | null>(null);
// A required attendee form holding the tickets back: fill it in now, or "Fill in later".
const [formPrompt, setFormPrompt] = useState<{ registrationId: string; needed: number; form: AttendeeFormDef; name?: string } | null>(null);
const promptForm = async (registrationId: string, needed: number, eventId: string, name?: string) => {
if (!(needed > 0)) return;
try {
const ev = await fetchEvent<Event>(eventId, { authToken: token || undefined });
setFormPrompt({ registrationId, needed, form: ev?.form, name });
} catch {}
};
// Load all users for client-side fuzzy matching
useEffect(() => {
@@ -164,7 +174,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiV2Data("post", "/payments", {
const paid = await (apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -174,8 +184,11 @@ export default function ManualRegistrationPage() {
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
},
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
}) as Promise<{ formsNeeded?: number; registration?: { eventId?: string } }>);
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).${(paid?.formsNeeded || 0) > 0 ? " The attendee form is holding the tickets back." : ""}`);
const paidReg = allRegistrations.find(r => r.id === payRegistrationId);
const paidEventId = paid?.registration?.eventId || paidReg?.eventId;
if (paidEventId) promptForm(payRegistrationId, paid?.formsNeeded || 0, paidEventId, paidReg?.user?.name);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
@@ -362,6 +375,7 @@ export default function ManualRegistrationPage() {
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.");
if ((res?.formsNeeded || 0) > 0) setFormPrompt({ registrationId: res.id, needed: res.formsNeeded || 0, form: fullEvent?.form, name: res.user?.name || guest.name });
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -771,6 +785,24 @@ export default function ManualRegistrationPage() {
</div>
</div>
)}
{formPrompt && (
<AttendeeFormModal
key={formPrompt.registrationId}
registrationId={formPrompt.registrationId}
form={formPrompt.form}
needed={formPrompt.needed}
token={token}
attendeeName={formPrompt.name}
onDone={({ later, ticketsIssued, sentTo }) => {
setFormPrompt(null);
const issued = ticketsIssued > 0 ? " Tickets issued." : "";
const text = later
? `Attendee form left for later${sentTo && sentTo.length ? ` — a link was sent by ${sentTo.join(" and ")}` : ""}.${issued}`
: `Attendee form saved.${issued}`;
if (tab === "payment") setPayMessage(text); else setMessage(text);
}}
/>
)}
</div>
);
}
@@ -3,13 +3,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
@@ -55,8 +56,8 @@ export default function OfflineScansPage() {
if (!token || !eventId) return;
try {
const [l, r] = await Promise.all([
apiFetch<Lock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token }),
apiFetch<Report[]>(`/api/scan-locks/reports?eventId=${encodeURIComponent(eventId)}${onlyOpen ? "&unreviewed=true" : ""}`, { authToken: token }),
(apiV2Data("get", "/scan-locks", { query: { eventId: eventId }, authToken: token }) as Promise<Lock[]>),
(apiV2Data("get", "/scan-locks/reports", { query: { eventId, ...(onlyOpen ? { unreviewed: "true" } : {}) }, authToken: token }) as Promise<Report[]>),
]);
setLocks(l);
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
@@ -68,14 +69,14 @@ export default function OfflineScansPage() {
const forceRelease = async (l: Lock) => {
try {
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
const res = await (apiV2Data("post", "/scan-locks/{id}/force-release", { params: { id: l.id }, authToken: token, body: {} }) as Promise<{ message: string }>);
setInfo(res.message);
load();
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
const review = async (r: Report) => {
try {
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
await apiV2Data("post", "/scan-locks/reports/{id}/review", { params: { id: r.id }, authToken: token, body: {} });
load();
} catch (e) { setError(errorMessage(e) || "Failed"); }
};
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/type
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -91,9 +91,9 @@ export default function SupervisorDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
(apiV2Data("get", "/stats/supervisor", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Settings = {
event: { id: string; title: string; volunteerOptionId: string | null };
@@ -39,7 +39,7 @@ export default function VolunteerRostersPage() {
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
(apiV2Data("get", "/volunteering/events", { query: { all: "1" }, authToken: token }) as Promise<RosterEvent[]>)
.then(list => {
setEvents(list);
const wanted = new URLSearchParams(window.location.search).get("event");
@@ -51,7 +51,7 @@ export default function VolunteerRostersPage() {
const loadSettings = useCallback(async () => {
if (!token || !eventId) { setSettings(null); return; }
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
setSettings(await (apiV2Data("get", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
}
@@ -61,7 +61,7 @@ export default function VolunteerRostersPage() {
useEffect(() => {
if (!token || tab !== "hours") return;
setHours(null);
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
(apiV2Data("get", "/volunteering/hours", { query: { eventId: !hoursAll && eventId ? eventId : undefined }, authToken: token }) as Promise<HoursRow[]>)
.then(setHours)
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
}, [token, tab, hoursAll, eventId, setMsg]);
@@ -69,7 +69,7 @@ export default function VolunteerRostersPage() {
const saveTicket = async (optionId: string | null, ok: string) => {
setBusy(true);
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
setSettings(await (apiV2Data("put", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token, body: { optionId } }) as Promise<Settings>));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -249,8 +249,9 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
await apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", {
params: { id: autoEventId },
authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
@@ -462,7 +463,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload(true) }) as Promise<MessageSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -482,7 +483,7 @@ function WhatsAppAttendeesPageInner() {
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload() }) as Promise<MessageSendResult>);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
@@ -501,7 +502,7 @@ function WhatsAppAttendeesPageInner() {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, kind: messageKind, scheduledAt: whenIso,
};
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<MessageSendResult>);
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
@@ -554,7 +555,7 @@ function WhatsAppAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "whatsapp" }, authToken: token }) as Promise<{ jobs: ScheduledJob[] }>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch { } finally { setLoadingScheduled(false); }
}, [token]);
@@ -569,7 +570,7 @@ function WhatsAppAttendeesPageInner() {
const body: { scheduledAt?: string; message?: string } = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.message = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
@@ -579,7 +580,7 @@ function WhatsAppAttendeesPageInner() {
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError("Not authenticated"); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
@@ -828,7 +829,7 @@ function WhatsAppAttendeesPageInner() {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/preview", { authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -840,7 +841,7 @@ function WhatsAppAttendeesPageInner() {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/send", { authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ""}`);
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
@@ -852,7 +853,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/schedule", { authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
@@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
@@ -23,7 +22,7 @@ export default function DonatePage() {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events");
const evs = await (apiV2Data("get", "/events") as Promise<Event[]>);
setEvents(evs);
if (evs.length > 0) setEventId(evs[0].id);
} catch (e) {
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { QrImage } from "@/components/shared/QrImage";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { FAMILY_QR_PREFIX, type KidsAdult, type KidsChild, type KidsFamily } from "@/lib/kids";
import { apiV2Data } from "@/lib/apiV2";
type ActiveCode = { code: string; qr: string; event: { id: string; title: string }; children: { name: string; room: string | null; checkedInAt: string }[] };
type Mine = { family: KidsFamily | null; qrToken: string | null; codes: ActiveCode[]; pages: { id: string; message: string; createdAt: string }[] };
@@ -31,7 +31,7 @@ export default function MyKidsPage() {
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/kids/me", { authToken: token }));
setData(await (apiV2Data("get", "/me/family", { authToken: token }) as Promise<Mine>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your family" });
}
@@ -56,22 +56,22 @@ export default function MyKidsPage() {
}
};
const startFamily = () => call(() => apiFetch("/api/kids/me", { method: "POST", authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const startFamily = () => call(() => apiV2Data("post", "/me/family", { authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const saveChild = (child: KidsChild | null, input: ChildInput) => call(
() => apiFetch(child ? `/api/kids/me/children/${child.id}` : "/api/kids/me/children", { method: child ? "PUT" : "POST", authToken: token, body: input }),
() => (child ? apiV2Data("put", "/me/family/children/{id}", { params: { id: child.id }, authToken: token, body: input }) : apiV2Data("post", "/me/family/children", { authToken: token, body: input })),
child ? "Saved." : "Child added.",
).then(ok => { if (ok) setEditing(null); });
const removeChild = (child: KidsChild) => {
if (!confirm(`Remove ${child.firstName}? Their details are deleted.`)) return;
call(() => apiFetch(`/api/kids/me/children/${child.id}`, { method: "DELETE", authToken: token }), "Removed.");
call(() => apiV2Data("delete", "/me/family/children/{id}", { params: { id: child.id }, authToken: token }), "Removed.");
};
const saveAdult = () => adultForm && call(
() => apiFetch(adultForm.id ? `/api/kids/me/adults/${adultForm.id}` : "/api/kids/me/adults", { method: adultForm.id ? "PUT" : "POST", authToken: token, body: adultForm }),
() => (adultForm.id ? apiV2Data("put", "/me/family/adults/{id}", { params: { id: adultForm.id }, authToken: token, body: adultForm }) : apiV2Data("post", "/me/family/adults", { authToken: token, body: adultForm })),
"Saved.",
).then(ok => { if (ok) setAdultForm(null); });
const removeAdult = (a: KidsAdult) => {
if (!confirm(`Remove ${a.name} from your authorised adults?`)) return;
call(() => apiFetch(`/api/kids/me/adults/${a.id}`, { method: "DELETE", authToken: token }), "Removed.");
call(() => apiV2Data("delete", "/me/family/adults/{id}", { params: { id: a.id }, authToken: token }), "Removed.");
};
const share = async (c: ActiveCode) => {
const text = `Pickup code for ${c.children.map(k => k.name.split(" ")[0]).join(", ")} at ${c.event.title}: ${c.code}`;
+15 -14
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
import { createFullPaymentCheckout, fetchEvent } from "@/lib/api";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
@@ -125,7 +125,7 @@ export default function UserDashboardPage() {
// their payments had already resolved.
const [myRegs, myTicks] = await Promise.all([
(apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>),
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
(apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>),
]);
setRegistrations(myRegs);
setTickets(myTicks);
@@ -230,11 +230,10 @@ export default function UserDashboardPage() {
// Open loading dialog immediately
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
const res = await (apiV2Data("post", "/me/tickets/email", {
body: { ticketIds },
authToken: token,
});
}) as Promise<{ message?: string }>);
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
@@ -250,11 +249,10 @@ export default function UserDashboardPage() {
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
try {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
const res = await (apiV2Data("post", "/me/tickets/email", {
body: { ticketIds, channel: "whatsapp" },
authToken: token,
});
}) as Promise<{ message?: string }>);
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e) {
@@ -422,7 +420,7 @@ export default function UserDashboardPage() {
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
setCodeInput("");
@@ -442,7 +440,7 @@ export default function UserDashboardPage() {
try {
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
setRegistrations(myRegs);
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
};
@@ -602,7 +600,7 @@ export default function UserDashboardPage() {
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await (apiV2Data("get", "/me/tickets", { authToken: token }) as Promise<TicketRecord[]>);
setTickets(myTicks);
} catch {}
// Recompute billing totals using the backend's tranche-aware totalDueComputed
@@ -921,7 +919,7 @@ export default function UserDashboardPage() {
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
const r = await (apiV2Data("delete", "/tickets/{id}/transfer", { params: { id: t.id }, authToken: token }) as Promise<{ message?: string }>);
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
@@ -1035,11 +1033,14 @@ export default function UserDashboardPage() {
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
<BundleSavings applications={activeReg.bundleApplications} className="mt-2" />
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) - Number(activeReg.complimentaryDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0) - Number(activeReg.complimentaryDiscount || 0))}
</div>
)}
{Number(activeReg.complimentaryDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">Complimentary — −{formatRand(Number(activeReg.complimentaryDiscount))}</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
@@ -3,11 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Megaphone, Download, PencilLine, Trash2, ListChecks, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
@@ -81,7 +82,7 @@ export default function PrivacyCentrePage() {
const load = useCallback(async () => {
if (!token) return;
try {
setInfo(await apiFetch<PrivacyInfo>("/api/privacy/me", { authToken: token }));
setInfo(await (apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<PrivacyInfo>));
setLoadError(null);
} catch (e) {
setLoadError(errorMessage(e) || "Could not load your privacy settings.");
@@ -99,7 +100,7 @@ export default function PrivacyCentrePage() {
// Show the new state straight away; load() puts back the saved one if it fails.
setInfo({ ...info, news: { ...info.news, [channel]: value } });
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { [channel]: value } });
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { [channel]: value } });
await load();
setNewsMsg({ type: "ok", text: value ? "You're subscribed." : "You're unsubscribed." });
} catch (e) {
@@ -118,10 +119,10 @@ export default function PrivacyCentrePage() {
setDownloading(format);
setDownloadMsg(null);
try {
const res = await fetch(`${API_BASE}/api/privacy/me/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
const res = await fetch(`${API_BASE}/api/v2/me/privacy/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || `Download failed (${res.status})`);
throw new Error(body?.error?.message || body?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -147,7 +148,7 @@ export default function PrivacyCentrePage() {
setSendingCorrection(true);
setCorrectionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", { method: "POST", authToken: token, body: { type: "correction", details: correction } });
const res = await (apiV2Data("post", "/me/privacy/requests", { authToken: token, body: { type: "correction", details: correction } }) as Promise<{ message?: string }>);
setCorrection("");
setCorrectionMsg({ type: "ok", text: res?.message || "Request sent." });
load();
@@ -170,11 +171,10 @@ export default function PrivacyCentrePage() {
setDeleting(true);
setDeleteMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", {
method: "POST",
const res = await (apiV2Data("post", "/me/privacy/requests", {
authToken: token,
body: { type: "deletion", password: deletePassword, details: deleteReason || undefined },
});
}) as Promise<{ message?: string }>);
setDeleteMsg({ type: "ok", text: res?.message || "Your account is closed." });
setTimeout(() => logout(), 3000);
} catch (e) {
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
@@ -12,7 +11,7 @@ import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn,
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -127,10 +126,10 @@ export default function UserProfilePage() {
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
method: "POST",
const res = await (apiV2Data("post", "/me/sessions/revoke", {
authToken: token,
});
body: {},
}) as Promise<{ token?: string; message?: string }>);
// Revoking sessions invalidates the token this device was using too —
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
@@ -150,7 +149,7 @@ export default function UserProfilePage() {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
const res = await (apiV2Data("get", "/me/activity", { authToken: token }) as Promise<ActivityEvent[]>);
setActivity(Array.isArray(res) ? res : []);
} catch {
// Non-critical — just leave the list empty rather than showing an error banner.
@@ -198,11 +197,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/enable", {
authToken: token,
body: { method: tfMethod, password: tfPassword },
});
}) as Promise<TwoFactorChallenge>);
setTfChallenge(res);
setTfCode("");
setTfPassword("");
@@ -220,11 +218,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/confirm", {
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
}) as Promise<{ twoFactorMethod: TwoFactorMethod }>);
const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
@@ -244,8 +241,7 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
await apiFetch("/api/users/2fa/disable", {
method: "POST",
await apiV2Data("post", "/me/two-factor/disable", {
authToken: token,
body: { password: tfPassword },
});
@@ -273,11 +269,10 @@ export default function UserProfilePage() {
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
method: "POST",
const res = await (apiV2Data("post", "/me/close", {
authToken: token,
body: { password: closePassword, deleteData },
});
}) as Promise<{ message?: string }>);
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
@@ -2,13 +2,13 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
import { apiV2Data } from "@/lib/apiV2";
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
type Msg = { type: "ok" | "err"; text: string } | null;
@@ -27,7 +27,7 @@ export default function ServingPage() {
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
setData(await (apiV2Data("get", "/me/volunteering", { authToken: token }) as Promise<Mine>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
}
@@ -42,7 +42,7 @@ export default function ServingPage() {
const loadRoster = useCallback(async (id: string) => {
if (!token || !id) { setRoster(null); return; }
try {
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
setRoster(await (apiV2Data("get", "/events/{id}/volunteer-roles", { params: { id: id }, authToken: token }) as Promise<Roster>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
}
@@ -65,10 +65,10 @@ export default function ServingPage() {
const askCover = (s: MySlot) => {
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
if (note === null) return;
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
call(() => apiV2Data("post", "/volunteer-slots/{id}/swap", { params: { id: s.slotId }, authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
};
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
&& call(() => apiV2Data("delete", "/volunteer-slots/{id}", { params: { id: s.slotId }, authToken: token }), "You're off the roster for this shift.");
const upcoming = (data?.slots || []).filter(s => !s.past);
const past = (data?.slots || []).filter(s => s.past);
@@ -109,7 +109,7 @@ export default function ServingPage() {
{!s.checkedInAt && (
<div className="mt-3 flex flex-wrap gap-2">
{s.swapId
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
? <button disabled={busy} onClick={() => call(() => apiV2Data("delete", "/volunteer-swaps/{id}", { params: { id: s.swapId! }, authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can&apos;t make it</button>}
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
</div>
@@ -137,7 +137,7 @@ export default function ServingPage() {
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can&apos;t make it{c.note ? `: “${c.note}”` : ""}</div>
</div>
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
<button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: c.swapId }, authToken: token, body: {} }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
</div>
))}
</section>
@@ -170,10 +170,10 @@ export default function ServingPage() {
{r.mine
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You&apos;re serving</span>
: r.coverNeeded.length > 0
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
? <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: r.coverNeeded[0].swapId }, authToken: token, body: {} }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
: r.full
? <span className="text-sm text-gray-500">Full</span>
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
: <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-roles/{id}/sign-up", { params: { id: r.id }, authToken: token, body: {} }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
</div>
))}
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
+1 -1
View File
@@ -32,7 +32,7 @@ type Event = {
};
export default async function EventsPage() {
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const events = await apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
+3 -2
View File
@@ -7,7 +7,8 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
@@ -28,7 +29,7 @@ function PrivacyNotice() {
useEffect(() => {
setNotice(null);
setError(null);
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
(apiV2Data("get", "/privacy/notice", { query: { version: versionParam || undefined } }) as Promise<PublicNotice>)
.then(setNotice)
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
}, [versionParam]);
+3 -3
View File
@@ -38,9 +38,9 @@ const FEATURES = [
async function getOrgName(): Promise<string> {
if (!API_BASE) return appName;
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
const settings = await res.json();
const settings = (await res.json())?.data;
return settings?.org_name || appName;
} catch {
return appName;
@@ -49,7 +49,7 @@ async function getOrgName(): Promise<string> {
export default async function HomePage() {
const [events, displayName] = await Promise.all([
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
@@ -75,7 +75,7 @@ function RegistrationSuccessContent() {
(async () => {
try {
if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
await apiV2Data("post", "/tickets/generate", { authToken: token, body: { registrationId: reg.id } });
}
} catch {}
setTimeout(() => router.replace('/dashboard'), 600);
+2 -2
View File
@@ -2,10 +2,10 @@
import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ResetPasswordContent() {
const search = useSearchParams();
@@ -24,7 +24,7 @@ function ResetPasswordContent() {
try {
setLoading(true);
setStatus(null);
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
await apiV2Data("post", "/auth/password/reset", { body: { token, password } });
setStatus("Your password has been reset. You can now log in.");
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
+49 -37
View File
@@ -2,7 +2,7 @@
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -13,7 +13,8 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
import { toAuthResponse } from "@/contexts/AuthContext";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -196,6 +197,9 @@ export default function SelfServicePage() {
// ── Event form (attendee form) state ────────────────────────────
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
// Attendee forms the server says this registration still needs (it counts places already
// on an existing registration, and a picked-session booking once).
const [formsToFill, setFormsToFill] = useState(0);
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
const [formSubmitLoading, setFormSubmitLoading] = useState(false);
@@ -263,16 +267,24 @@ export default function SelfServicePage() {
}), [quantities]);
const bundleQuote = useBundleQuote({ eventId: selectedEventId || "", basket: kioskBasket, token: supervisorToken, forUserId: matchedAccount?.id ?? null });
// Does the selected event have a required form?
// Does the selected event have a required form? (Statements and headings aren't questions.)
const eventHasRequiredForm = useMemo(() => {
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement'));
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement' && f.type !== 'paragraph'));
}, [selectedEvent]);
// Answerable form fields (exclude statement-type display fields)
// Answerable form fields (exclude statement and heading display fields)
const answerableFields = useMemo(() => {
if (!selectedEvent?.form) return [];
return (selectedEvent.form.fields || [])
.filter(f => f.type !== 'statement')
.filter(f => f.type !== 'statement' && f.type !== 'paragraph')
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [selectedEvent]);
// Statements and headings, shown above the questions
const formTexts = useMemo(() => {
if (!selectedEvent?.form) return [];
return (selectedEvent.form.fields || [])
.filter(f => f.type === 'statement' || f.type === 'paragraph')
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [selectedEvent]);
@@ -337,15 +349,16 @@ export default function SelfServicePage() {
try {
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
let data: AuthResponse | TwoFactorChallenge;
if (setupChallenge) {
const { data: session } = await apiV2("post", "/auth/login/code", { body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false } });
data = toAuthResponse(session);
} else {
const { data: res } = await apiV2("post", "/auth/login", { body: { identifier: setupEmail, password: setupPassword } });
data = res.status === "code_required"
? { twoFactorRequired: true, challengeId: res.challengeId, method: res.method, destination: res.destination || "" }
: toAuthResponse(res);
}
if (isTwoFactorChallenge(data)) {
setSetupChallenge(data);
setSetupCode("");
@@ -394,8 +407,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 },
});
@@ -485,11 +497,13 @@ export default function SelfServicePage() {
}) as Promise<Registration>);
setCurrentRegistrationId(result?.id || null);
const needed = result?.formsNeeded ?? (eventHasRequiredForm ? mainTicketCount : 0);
setFormsToFill(needed);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
if (eventHasRequiredForm && mainTicketCount > 0 && result?.id) {
// If the required form still holds the tickets back, show the form step
if (needed > 0 && result?.id) {
setCurrentTicketIndex(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
@@ -527,7 +541,7 @@ export default function SelfServicePage() {
const collectedAnswers = [...allFormAnswers, currentTicketAnswers];
// If more tickets remain, advance to the next one
if (currentTicketIndex < mainTicketCount - 1) {
if (currentTicketIndex < formsToFill - 1) {
setAllFormAnswers(collectedAnswers);
setCurrentTicketAnswers({});
setCurrentTicketIndex((i) => i + 1);
@@ -605,6 +619,7 @@ export default function SelfServicePage() {
setFormError(null);
setCurrentRegistrationId(null);
setCurrentTicketIndex(0);
setFormsToFill(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
setFormSubmitError(null);
@@ -635,10 +650,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.");
@@ -1218,13 +1233,13 @@ export default function SelfServicePage() {
<div className="text-center mb-6">
<p className="text-sm text-gray-500 uppercase tracking-wide font-medium">{selectedEvent.title}</p>
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
{mainTicketCount > 1 && (
{formsToFill > 1 && (
<div className="mt-3">
<p className="text-base font-semibold text-brand-700">
Attendee {currentTicketIndex + 1} of {mainTicketCount}
Attendee {currentTicketIndex + 1} of {formsToFill}
</p>
<div className="flex gap-1.5 justify-center mt-2">
{Array.from({ length: mainTicketCount }).map((_, i) => (
{Array.from({ length: formsToFill }).map((_, i) => (
<div
key={i}
className={`h-2 rounded-full transition-all ${
@@ -1239,12 +1254,18 @@ export default function SelfServicePage() {
</div>
</div>
)}
{mainTicketCount <= 1 && (
{formsToFill <= 1 && (
<p className="text-sm text-gray-500 mt-1">This form is required to complete your registration.</p>
)}
</div>
<form onSubmit={handleFormSubmit} className="space-y-5">
{formTexts.map((field) => (
<div key={field.id} className={field.type === "paragraph" ? "text-base font-semibold text-gray-800" : "text-sm text-gray-600"}>
{field.label}
{field.helpText && <p className="text-sm font-normal text-gray-500 mt-1">{field.helpText}</p>}
</div>
))}
{answerableFields.map((field) => (
<div key={field.id}>
<label htmlFor={`kiosk-field-${field.id}`} className="block text-sm font-medium text-gray-700 mb-1">
@@ -1291,15 +1312,6 @@ export default function SelfServicePage() {
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "paragraph" ? (
<textarea
id={`kiosk-field-${field.id}`}
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
rows={3}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
/>
) : (
<input
id={`kiosk-field-${field.id}`}
@@ -1326,8 +1338,8 @@ export default function SelfServicePage() {
>
{formSubmitLoading
? "Submitting…"
: currentTicketIndex < mainTicketCount - 1
? `Next — Attendee ${currentTicketIndex + 2} of ${mainTicketCount}`
: currentTicketIndex < formsToFill - 1
? `Next — Attendee ${currentTicketIndex + 2} of ${formsToFill}`
: "Complete Registration"}
</button>
</form>
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
const series = await loadSeries(slug);
if (!series) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
+2 -2
View File
@@ -6,9 +6,9 @@ import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { CheckCircle2, HandHeart } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
@@ -23,7 +23,7 @@ function CheckIn() {
useEffect(() => {
if (!code || loading || !token || sent.current) return;
sent.current = true;
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
(apiV2Data("post", "/volunteering/check-in", { authToken: token, body: { qr: code } }) as Promise<Result>)
.then(setResult)
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
}, [code, loading, token]);

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