Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51992814d2 | ||
|
|
88d9276960 | ||
|
|
1ae4c47001 | ||
|
|
3693d5f2c8 | ||
|
|
62b10c3db5 | ||
|
|
7ce25bf40b | ||
|
|
376b93a438 | ||
|
|
0cd78ab1b7 | ||
|
|
d49aebb1b0 | ||
|
|
98e5d05548 | ||
|
|
14ece27690 | ||
|
|
5b50b0ce4f | ||
|
|
99f16375b9 | ||
|
|
a62cac6335 | ||
|
|
c4fff7c835 | ||
|
|
e310743b92 | ||
|
|
b6c13de5dc | ||
|
|
2a5b12db20 | ||
|
|
8747431bd5 | ||
|
|
e95d52ccdd | ||
|
|
fdc3db8d23 | ||
|
|
1a25896999 | ||
|
|
97c368c898 |
+178
@@ -7,6 +7,184 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.52.2] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- **`scripts/deploy.sh` stops before touching anything when files in the install folder were
|
||||
changed or added by hand**, and lists them. A setup wizard run on one server had added files to
|
||||
`backend/src` that the backend build picked up, so the deploy failed halfway and rolled back.
|
||||
Ignored files (`.env`, uploads, backups, `ecosystem.config.js`) never count.
|
||||
`ALLOW_LOCAL_CHANGES=1` deploys anyway.
|
||||
- `bash scripts/deploy.sh --check` runs only that check (no backup, fetch, build or reload). The
|
||||
script can also be run from a copy outside the install (e.g. `/tmp/deploy.sh`, from the folder
|
||||
you're in), so an older install can check before updating — see DEPLOYMENT.md, "Updating".
|
||||
|
||||
## [1.52.1] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- **The API documentation (`/docs/v2`, `GET /api/v2/openapi.json`) now describes every route in
|
||||
full.** Before, most routes answered by a v1 handler showed an empty body and `data: unknown`.
|
||||
Each one now documents the body it reads and what it answers, from the handler's actual code, and
|
||||
every path and query parameter has a description. Actions that take no input say "No body
|
||||
needed".
|
||||
- How: `documented: { body?, response? }` on `v1Route` (and `defineRoute`), with shared shapes in
|
||||
`src/v2/docs.ts`. These are documentation only — requests and answers aren't checked against
|
||||
them, so nothing an app already does can start failing — but outside production a body or
|
||||
answer that doesn't fit is logged (`[api/v2] … doesn't match its documented schema`).
|
||||
`tests/v2Docs.test.ts` keeps the document complete.
|
||||
- The funnel report's `eventIds` and the webhook deliveries' `status`/`before` filters are now
|
||||
declared (they worked, but weren't in the document or the typed frontend calls).
|
||||
- The frontend's generated API types are richer; a few calls had their types tightened to match
|
||||
(ticket transfer, breach register, kids check-out). Nothing changes in what they send.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -311,6 +311,24 @@ and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
|
||||
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
|
||||
or checks anything out: they would be built into the release, or block the checkout. Move added
|
||||
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
|
||||
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
|
||||
|
||||
```bash
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh --check
|
||||
```
|
||||
|
||||
An install still on an older release can run the new check without updating anything — fetch the
|
||||
script on its own and run it from the install folder:
|
||||
|
||||
```bash
|
||||
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
|
||||
bash /tmp/deploy.sh --check
|
||||
```
|
||||
|
||||
### Upgrading Node
|
||||
|
||||
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
|
||||
|
||||
+15
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,60 @@ 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.2
|
||||
|
||||
- **Safer updates.** If files on your server were changed by hand, an update now stops straight
|
||||
away and says which files, instead of failing part-way. Nothing else changes.
|
||||
|
||||
## 1.52.1
|
||||
|
||||
- **Better documentation for developers.** If someone builds on your Cross Events site (for example
|
||||
an integration or a website plug-in), the API reference now explains every part of it in full.
|
||||
Nothing changes in how the site works.
|
||||
|
||||
## 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,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"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;
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -46,6 +46,148 @@ 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' },
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Shapes for the API documentation (1.52.1): the records v1 handlers answer with, and the bodies
|
||||
* they take, described once so every route shows the same thing the same way. Used as
|
||||
* `documented: { body, response }` on v1Route (see RouteDef.documented) — shown in the OpenAPI
|
||||
* document, not enforced; outside production anything that doesn't fit is logged.
|
||||
*
|
||||
* Everything is a loose object (extra fields pass, as v1 answers carry their Prisma includes),
|
||||
* dates are ISO strings (as they arrive in JSON), and fields a handler may leave out are optional.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export const obj = <T extends z.ZodRawShape>(shape: T) => z.looseObject(shape);
|
||||
export const str = z.string();
|
||||
export const optStr = z.string().nullish();
|
||||
export const num = z.number();
|
||||
export const optNum = z.number().nullish();
|
||||
export const bool = z.boolean();
|
||||
export const optBool = z.boolean().nullish();
|
||||
export const date = z.string().describe('Date and time (ISO 8601)');
|
||||
export const optDate = date.nullish();
|
||||
export const rand = z.number().describe('Rand');
|
||||
export const optRand = rand.nullish();
|
||||
export const anyObj = z.looseObject({});
|
||||
/** A number sent as a number or as text (forms send text). */
|
||||
export const numeric = z.union([z.number(), z.string()]);
|
||||
export const ids = z.array(z.string()).describe('Ids');
|
||||
export const channel = z.enum(['email', 'whatsapp', 'both']).describe('How to send it');
|
||||
|
||||
/** `{ message }`: what was done, in words for the person. */
|
||||
export const message = obj({ message: str });
|
||||
/** `{ ok: true }` */
|
||||
export const ok = obj({ ok: z.literal(true) });
|
||||
/** v1's paged lists: `{ data, total, page, limit, pages }`. */
|
||||
export const v1Page = <T extends z.ZodType>(item: T) => obj({ data: z.array(item), total: num, page: num, limit: num, pages: num });
|
||||
/** A `{ redirectUrl }` answer from a checkout: where to send the person to pay. */
|
||||
export const checkout = obj({
|
||||
checkoutId: optStr,
|
||||
redirectUrl: optStr.describe('Where to send the person to pay (null when the provider takes a form)'),
|
||||
provider: optStr,
|
||||
form: obj({ action: str, fields: z.record(z.string(), z.string()) }).nullish().describe('A form to post (PayFast)'),
|
||||
});
|
||||
|
||||
export const userSummary = obj({ id: str.optional(), name: str, email: optStr, phoneNumber: optStr });
|
||||
export const userRef = obj({ id: str.optional(), name: str, email: optStr });
|
||||
|
||||
export const user = obj({
|
||||
id: str,
|
||||
name: str,
|
||||
email: str,
|
||||
phoneNumber: optStr,
|
||||
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
|
||||
isActive: optBool,
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']).nullish(),
|
||||
homeCampusId: optStr,
|
||||
createdAt: optDate,
|
||||
});
|
||||
|
||||
export const eventRef = obj({ id: str, title: str, startDate: optDate, endDate: optDate });
|
||||
|
||||
export const event = obj({
|
||||
id: str,
|
||||
title: str,
|
||||
description: optStr,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
location: optStr,
|
||||
price: optRand,
|
||||
isActive: optBool,
|
||||
isHidden: optBool,
|
||||
imageUrl: optStr,
|
||||
campusId: optStr,
|
||||
scheduleType: z.enum(['single', 'sessions']).nullish(),
|
||||
cashupStatus: z.enum(['open', 'closed']).nullish(),
|
||||
});
|
||||
|
||||
export const session = obj({ id: str, label: optStr, startsAt: date, endsAt: date, cancelledAt: optDate, capacity: optNum });
|
||||
|
||||
export const eventOption = obj({
|
||||
id: str,
|
||||
eventId: str.optional(),
|
||||
name: str,
|
||||
description: optStr,
|
||||
price: rand,
|
||||
isMainTicket: bool,
|
||||
stockLimit: optNum,
|
||||
});
|
||||
|
||||
export const payment = obj({
|
||||
id: str,
|
||||
amount: rand.describe('Rand; a refund is negative'),
|
||||
method: str.describe('cash, card, eft, other — or the wallet type for an online payment'),
|
||||
provider: optStr.describe('yoco or payfast for an online payment'),
|
||||
userId: str,
|
||||
recordedById: optStr,
|
||||
registrationId: optStr,
|
||||
eventId: optStr,
|
||||
isDonation: bool,
|
||||
isSponsorship: optBool,
|
||||
originalPaymentId: optStr.describe('A refund: the payment it refunds; a donation leg: the donation'),
|
||||
externalId: optStr,
|
||||
createdAt: date,
|
||||
user: userSummary.nullish(),
|
||||
recordedBy: userRef.nullish(),
|
||||
event: eventRef.nullish(),
|
||||
});
|
||||
|
||||
export const registrationStatus = z.enum(['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled']);
|
||||
|
||||
export const registration = obj({
|
||||
id: str,
|
||||
userId: str,
|
||||
eventId: str,
|
||||
status: registrationStatus,
|
||||
discountTotal: optRand,
|
||||
totalDueComputed: optRand.describe('What it costs now, after discounts'),
|
||||
createdAt: date,
|
||||
user: userSummary.nullish(),
|
||||
event: eventRef.nullish(),
|
||||
});
|
||||
|
||||
export const ticket = obj({
|
||||
id: str,
|
||||
qrCode: str.describe('What the QR code holds'),
|
||||
quantity: num.describe('People on the ticket'),
|
||||
isUsed: bool,
|
||||
eventId: str,
|
||||
userId: str.describe('The payer'),
|
||||
registrationOptionId: str,
|
||||
attendeeId: optStr.describe('A named ticket: the person it\'s for'),
|
||||
holderUserId: optStr,
|
||||
createdAt: date,
|
||||
});
|
||||
|
||||
export const auditEntry = obj({
|
||||
id: str,
|
||||
action: str,
|
||||
actorRole: optStr,
|
||||
targetType: optStr,
|
||||
targetId: optStr,
|
||||
metadata: anyObj.nullish(),
|
||||
ip: optStr,
|
||||
createdAt: date,
|
||||
actor: userRef.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* What the common query parameters mean, for routes that declare them by name only (`filters(…)`
|
||||
* in the resource files). The OpenAPI document uses these when a parameter has no description.
|
||||
*/
|
||||
export const QUERY_HELP: Record<string, string> = {
|
||||
page: 'Page number, from 1',
|
||||
limit: 'How many per page',
|
||||
eventId: 'Only this event',
|
||||
eventIds: 'These events (comma-separated ids)',
|
||||
registrationId: 'Only this registration',
|
||||
userId: 'Only this person',
|
||||
actorId: 'Only things this person did',
|
||||
sessionId: 'Multi-day events: the session (default: today\'s)',
|
||||
deviceId: 'This device\'s id (offline scanning)',
|
||||
from: 'On or after (ISO date or date-time)',
|
||||
to: 'On or before (ISO date or date-time)',
|
||||
startDate: 'On or after (ISO date)',
|
||||
endDate: 'On or before (ISO date)',
|
||||
before: 'Older than (ISO date-time), for the next page',
|
||||
cursor: 'Where the last page ended (its `nextCursor`)',
|
||||
search: 'Text to search for',
|
||||
q: 'Text to search for',
|
||||
status: 'Only this status',
|
||||
role: 'admin, supervisor, staff or user',
|
||||
isActive: '`true` or `false`',
|
||||
campus: 'Campus id or slug (`all` for every campus)',
|
||||
series: 'Event series slug',
|
||||
format: 'File type, e.g. `pdf` or `xlsx`',
|
||||
log: '`1` also writes it to the access log (a list with personal details)',
|
||||
all: '`true`/`1`: include everything, not just the current ones',
|
||||
medical: '`true`: include medical notes (supervisors; logged)',
|
||||
type: 'Which kind',
|
||||
action: 'Only this action',
|
||||
sensitive: '`true`: only access to sensitive information',
|
||||
version: 'A version number (default: the current one)',
|
||||
provider: 'yoco or payfast',
|
||||
method: 'Payment method: cash, card, eft, other',
|
||||
kind: 'payment or refund',
|
||||
donations: '`1`: only donations and the parts put towards registrations',
|
||||
channel: 'email or whatsapp',
|
||||
email: 'An email address',
|
||||
phone: 'A phone number',
|
||||
phoneNumber: 'A phone number',
|
||||
past: '`true`: include ones that are over too',
|
||||
unpaid: '`true`: only ones with money still owed',
|
||||
unreviewed: '`true`: only the ones not reviewed yet',
|
||||
seriesId: 'Only this event series',
|
||||
registrants: '`true`: also people registered for its events in the last 12 months',
|
||||
};
|
||||
|
||||
/** What path parameters are, when the route doesn't say. */
|
||||
export const PATH_HELP: Record<string, string> = {
|
||||
id: 'The id',
|
||||
userId: 'The person\'s id',
|
||||
attendeeId: 'The named attendee\'s id',
|
||||
attachmentId: 'The attachment\'s id',
|
||||
familyId: 'The family\'s id',
|
||||
childId: 'The child\'s id',
|
||||
slug: 'The short name in the address',
|
||||
token: 'The token from the link (it is the credential)',
|
||||
qrCode: 'What the ticket\'s QR code holds',
|
||||
code: 'The code',
|
||||
name: 'The name',
|
||||
filename: 'The file name',
|
||||
};
|
||||
@@ -16,6 +16,10 @@ 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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { routes as allRoutes, type AnyRoute, type Access } from './route';
|
||||
import { ERROR_STATUS } from './errors';
|
||||
import { PATH_HELP, QUERY_HELP } from './docs';
|
||||
|
||||
/**
|
||||
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
|
||||
@@ -48,7 +49,8 @@ function parameters(def: AnyRoute) {
|
||||
if (!schema) return;
|
||||
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
|
||||
for (const [name, prop] of Object.entries(json.properties || {})) {
|
||||
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
|
||||
const { description: own, ...propSchema } = prop as JsonSchema & { description?: string };
|
||||
const description = own || (where === 'query' ? QUERY_HELP[name] : PATH_HELP[name]);
|
||||
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
|
||||
}
|
||||
};
|
||||
@@ -57,6 +59,14 @@ function parameters(def: AnyRoute) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A JSON body; one with nothing in it (an action that takes no input) says so. */
|
||||
function requestBody(schema: JsonSchema) {
|
||||
const empty = !(schema as { properties?: object }).properties || !Object.keys((schema as { properties: object }).properties).length;
|
||||
return empty
|
||||
? { required: true, description: 'No body needed: send an empty object.', content: { 'application/json': { schema } } }
|
||||
: { required: true, content: { 'application/json': { schema } } };
|
||||
}
|
||||
|
||||
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
|
||||
const list = opts.list ?? allRoutes;
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
@@ -73,11 +83,11 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
|
||||
parameters: parameters(def),
|
||||
...(def.upload
|
||||
? { requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: [def.upload.field], properties: { [def.upload.field]: { type: 'string', format: 'binary', description: def.upload.description } } } } } } }
|
||||
: def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
|
||||
: (def.documented?.body || def.body) ? { requestBody: requestBody(toSchema((def.documented?.body || def.body)!, 'input')) } : {}),
|
||||
responses: {
|
||||
[String(def.status ?? 200)]: def.file
|
||||
? { description: 'The file', content: { [def.file.contentType]: { schema: { type: 'string' } } } }
|
||||
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
|
||||
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.documented?.response ? z.object({ data: def.documented.response }) : def.response, 'output') } } },
|
||||
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
|
||||
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
|
||||
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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';
|
||||
import * as D from '../docs';
|
||||
|
||||
/* 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 });
|
||||
|
||||
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
|
||||
|
||||
const news = D.obj({ email: D.bool, whatsapp: D.bool });
|
||||
const dataRequest = D.obj({
|
||||
id: D.str, type: z.enum(['access', 'correction', 'deletion']), status: z.enum(['open', 'completed', 'rejected', 'cancelled']),
|
||||
userId: D.optStr, name: D.str.optional(), email: D.str.optional(), details: D.optStr, response: D.optStr, dueAt: D.optDate.describe('30 days after it was made'),
|
||||
closedAt: D.optDate, createdAt: D.date,
|
||||
});
|
||||
const noticeSection = D.obj({ key: D.str.optional(), title: D.str, body: D.str.describe('Text; {placeholders} are filled in') });
|
||||
const operatorInput = D.obj({ name: D.str, purpose: D.str.describe('What they do for the church'), dataShared: D.optStr, location: D.optStr.describe('Country the data is kept in'), website: D.optStr, active: D.optBool, order: D.optNum });
|
||||
const operator = operatorInput.extend({ id: D.str, active: D.bool, order: D.num, createdAt: D.date, updatedAt: D.date });
|
||||
const logPage = <T extends z.ZodType>(row: T) => D.obj({ total: D.num, pageSize: D.num, rows: z.array(row) });
|
||||
const breachInput = D.obj({
|
||||
title: D.str, description: D.str, dataAffected: D.str, peopleAffected: D.numeric.nullish(), whoAffected: D.optStr, consequences: D.optStr,
|
||||
actionsTaken: D.optStr, adviceToPeople: D.optStr, causedBy: D.optStr, status: D.optStr.describe('open or closed'),
|
||||
discoveredAt: D.date, occurredAt: D.optDate, regulatorNotifiedAt: D.optDate.describe('When the Information Regulator was told'), peopleNotifiedAt: D.optDate,
|
||||
});
|
||||
const breach = breachInput.extend({ id: D.str, status: D.str, createdAt: D.date, updatedAt: D.date, createdBy: D.obj({ name: D.str }).nullish() });
|
||||
const retentionState = D.obj({
|
||||
policies: D.anyObj.describe('Per kind of information: how long it is kept'), definitions: D.anyObj, preview: D.anyObj.describe('What the rules would remove now'),
|
||||
lastRun: D.anyObj.nullish(), related: D.obj({ medicalDays: D.num, backupCount: D.num, analyticsDays: D.num }),
|
||||
});
|
||||
const hubStatus = D.obj({
|
||||
state: D.str.describe('active, grace, suspended, revoked, none…'), readOnly: D.bool, daysLeft: D.optNum, endsAt: D.optDate, graceEndsAt: D.optDate,
|
||||
licence: D.obj({ customer: D.optStr, plan: D.optStr, status: D.optStr, licenceExpiresAt: D.optDate, graceDays: D.optNum, issuedAt: D.optDate, validUntil: D.optDate, limits: D.anyObj }).nullish(),
|
||||
connected: D.bool, keyConfigured: D.bool, hubUrl: D.optStr, lastHeartbeatAt: D.optDate, lastError: D.optStr,
|
||||
version: D.str.describe('This site\'s version'), latestVersion: D.optStr, updateAvailable: D.bool, urgentUpdate: D.bool, updateSnoozed: D.optBool,
|
||||
releases: z.array(D.anyObj).optional().describe('What\'s new in each version since this one'), updateRequest: D.anyObj.nullish(), notices: z.array(D.anyObj).optional(),
|
||||
});
|
||||
const webhook = D.obj({
|
||||
id: D.str, name: D.str, url: D.str, events: z.array(D.str).describe('Event types it gets'), isActive: D.bool, operatorId: D.optStr.describe('Its entry in the operator register'),
|
||||
lastSuccessAt: D.optDate, lastFailureAt: D.optDate, createdAt: D.date,
|
||||
});
|
||||
const delivered = D.obj({ status: D.optStr.describe('succeeded, failed, retrying…'), statusCode: D.optNum, error: D.optStr });
|
||||
|
||||
// ─── 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', documented: { response: D.obj({ news, newsAskedAt: D.optDate, canWhatsApp: D.bool, notificationPreference: D.optStr, history: z.array(D.anyObj).describe('Every change to my news consent'), requests: z.array(dataRequest) }) }, v1: privacy.getMyPrivacy });
|
||||
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, documented: { body: D.obj({ email: D.optBool, whatsapp: D.optBool, source: D.optStr.describe('Where it was changed, for the consent log') }), response: D.obj({ news, newsAskedAt: D.optDate }) }, 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, documented: { body: D.obj({ type: z.enum(['correction', 'deletion']), details: D.optStr.describe('A correction: what to change'), password: D.optStr.describe('A deletion: closes the account too') }), response: D.obj({ request: dataRequest, message: D.str }) }, v1: privacy.createMyRequest });
|
||||
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, documented: { response: D.obj({ version: D.num.describe('The version accepted') }) }, v1: privacy.acceptMyNotice });
|
||||
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), documented: { response: D.obj({ version: D.num.describe('0: the built-in text'), builtIn: D.optBool, current: D.optBool, publishedAt: D.optDate, effectiveDate: D.optStr, changeSummary: D.optStr, sections: z.array(D.anyObj).describe('Rendered sections'), versions: z.array(D.anyObj).describe('Every published 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, documented: { response: D.obj({ kind: z.enum(['account', 'address']), email: D.optStr.describe('Partly hidden'), hasPhone: D.optBool, news: D.obj({ email: D.bool, whatsapp: D.bool }).optional(), address: D.optStr.describe('kind address: the email or phone, partly hidden') }) }, v1: privacy.getUnsubscribe });
|
||||
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, documented: { body: D.obj({ channels: z.array(z.enum(['email', 'whatsapp'])).optional().describe('Default: the link\'s channel') }), response: D.message }, 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'), documented: { response: D.obj({ requests: z.array(dataRequest.extend({ overdue: D.optBool, blockers: z.array(D.anyObj).optional().describe('Upcoming registrations that stop a deletion') })), openCount: D.num, total: D.num, page: D.num, limit: D.num, pages: D.num }) }, 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, documented: { body: D.obj({ response: D.optStr.describe('Emailed to them; needed for a correction') }), response: D.obj({ request: dataRequest, anonymised: D.anyObj.nullish().describe('A deletion: what was removed') }) }, 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, documented: { body: D.obj({ response: D.str.describe('The reason, emailed to them'), cancelled: D.optBool.describe('They withdrew it') }), response: D.obj({ request: dataRequest }) }, v1: privacy.rejectRequest });
|
||||
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', documented: { response: D.obj({ sections: z.array(noticeSection), isDraft: D.optBool, rendered: z.array(D.anyObj).describe('The sections with placeholders filled in'), placeholders: z.array(D.str).describe('The placeholders the text can use'), versions: z.array(D.anyObj).describe('Published versions'), builtIn: z.array(noticeSection), builtInUpdates: z.array(D.anyObj).describe('Changes to the built-in text not reviewed yet') }) }, v1: privacy.getNoticeDraft });
|
||||
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, documented: { body: D.obj({ sections: z.array(noticeSection) }), response: D.obj({ sections: z.array(noticeSection), rendered: z.array(D.anyObj), isDraft: z.literal(true), savedAt: D.date }) }, v1: privacy.saveNoticeDraft });
|
||||
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', documented: { response: D.message }, 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, documented: { body: D.obj({ sections: z.array(noticeSection).optional(), changeSummary: D.str.describe('Shown to people asked to accept it'), requiresAcceptance: D.optBool }), response: D.obj({ version: D.num, publishedAt: D.date, requiresAcceptance: D.bool }) }, 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, documented: { response: D.obj({ builtInUpdates: z.array(D.anyObj) }) }, v1: privacy.markBuiltInReviewed });
|
||||
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', documented: { response: z.array(operator) }, v1: privacy.listOperators });
|
||||
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, documented: { body: operatorInput, response: operator }, v1: privacy.createOperator });
|
||||
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, documented: { body: operatorInput.partial(), response: operator }, v1: privacy.updateOperator });
|
||||
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, documented: { response: D.message }, v1: privacy.deleteOperator });
|
||||
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), documented: { response: logPage(D.obj({ id: D.str, at: D.optDate, name: D.optStr })).describe('`type` news: email, channel, granted, source, ip; notice: email, version, source, ip; parental: child, event, status, signedName') }, 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'), documented: { response: logPage(D.obj({ id: D.str, at: D.date, actor: D.str, actorRole: D.optStr, action: D.str, what: D.optStr, records: D.optNum, eventId: D.optStr, ip: D.optStr, sensitive: D.bool })).extend({ actions: z.array(D.str), actors: z.array(D.obj({ id: D.str, name: D.str })) }) }, v1: privacy.accessLog });
|
||||
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', documented: { response: D.obj({ score: D.num.describe('%'), passed: D.num, total: D.num, checks: z.array(D.obj({ key: D.str, label: D.str, ok: D.bool, detail: D.optStr, href: D.optStr, applies: D.bool })) }) }, v1: privacy.getReadiness });
|
||||
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', documented: { response: retentionState }, v1: privacy.getRetention });
|
||||
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, documented: { body: D.obj({ policies: D.anyObj.describe('Per kind of information: { enabled, months }') }), response: retentionState }, v1: privacy.saveRetention });
|
||||
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, documented: { response: D.obj({ result: D.anyObj.describe('What was removed or anonymised'), state: retentionState }) }, v1: privacy.runRetentionNow });
|
||||
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', documented: { response: z.array(breach) }, v1: privacy.listBreaches });
|
||||
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, documented: { body: breachInput, response: breach }, v1: privacy.createBreach });
|
||||
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, documented: { body: breachInput.partial(), response: breach }, 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'), documented: { response: D.obj({ rows: z.array(D.auditEntry), total: D.num, page: D.num, limit: D.num }) }, v1: audit.listAuditLog });
|
||||
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', documented: { response: D.obj({ mode: D.str.describe('on, off or timers'), timezone: D.str, jobs: z.array(D.obj({ name: D.str, label: D.str, cron: D.optStr, enabled: D.bool, envFlag: D.optStr, nextRun: D.optDate, recent: z.array(D.anyObj).describe('Recent runs') })) }) }, 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, documented: { response: D.obj({ queued: D.bool, id: D.optStr, output: z.unknown().optional().describe('When it ran straight away') }) }, 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'), documented: { response: z.array(D.obj({ id: D.str, reason: D.optStr, startedAt: D.optDate, endedAt: D.optDate, expiresAt: D.optDate, admin: D.obj({ name: D.str, email: D.optStr }).nullish(), target: D.obj({ name: D.str, email: D.optStr }).nullish(), requests: z.array(D.obj({ method: D.str, path: D.str, blocked: D.bool, createdAt: D.date })).describe('What they did (up to 500)') })) }, 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, documented: { body: D.obj({ reason: D.str.describe('Why — kept in the log') }), response: D.obj({ token: D.str.describe('A read-only sign-in token'), session: D.obj({ id: D.str, expiresAt: D.date, target: D.obj({ id: D.str, name: D.str, email: D.str, role: D.str }) }) }) }, v1: imp.startImpersonation });
|
||||
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', documented: { response: D.obj({ id: D.str, expiresAt: D.date, target: D.userRef }).nullable() }, v1: imp.currentImpersonation });
|
||||
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, documented: { response: D.message }, 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, documented: { body: D.obj({ path: D.str }), response: z.null().describe('Always 204, no content') }, 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', documented: { response: z.record(z.string(), z.string()).describe('Setting key → value: name, logo, colours, contact details, what\'s switched on') }, v1: settings.getSettings });
|
||||
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', documented: { response: z.record(z.string(), z.unknown()).describe('Setting key → value; secrets masked') }, v1: settings.getAllSettings });
|
||||
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, documented: { body: z.record(z.string(), z.unknown()).describe('Setting key → new value'), response: D.message }, v1: settings.updateSettings });
|
||||
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, documented: { body: D.obj({ host: D.optStr, port: D.numeric.optional(), secure: z.union([D.bool, D.str]).optional(), user: D.optStr, pass: D.optStr, from: D.optStr }).describe('Unsaved SMTP details to try (default: the saved ones)'), response: D.message }, v1: settings.testSmtp });
|
||||
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', documented: { response: D.obj({ needsSetup: D.bool }) }, 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, documented: { body: D.obj({ adminName: D.str, adminEmail: D.str, adminPassword: D.str.describe('At least 8 characters') }), response: D.obj({ token: D.str, name: D.str, email: D.str }) }, v1: settings.setupRegister });
|
||||
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, documented: { body: D.obj({ settings: z.record(z.string(), z.unknown()).describe('Setting key → value') }), response: D.message }, v1: settings.runSetup });
|
||||
|
||||
// ─── Hub ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', documented: { response: hubStatus }, v1: hub.getStatus });
|
||||
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, documented: { body: D.obj({ licenceKey: D.str }), response: hubStatus }, v1: hub.connect });
|
||||
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.heartbeatNow });
|
||||
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.requestUpdate });
|
||||
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.cancelUpdateRequest });
|
||||
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, documented: { body: D.obj({ snooze: D.optBool.describe('false: stop putting it off') }), response: hubStatus }, 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')], documented: { response: z.array(D.obj({ id: D.str, level: D.str.describe('info, warning, critical'), title: D.str, message: D.str, startsAt: D.optDate, endsAt: D.optDate, updatedAt: D.optDate })) }, v1: hub.getNotices,
|
||||
});
|
||||
|
||||
// ─── Outbound webhooks ───────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', documented: { response: D.obj({ eventTypes: z.array(D.anyObj).describe('Every event type, with an example body'), keepDays: D.num.describe('How long deliveries are kept'), endpoints: z.array(webhook.extend({ lastWeek: z.record(z.string(), z.number()).describe('Status → deliveries in the last 7 days') })) }) }, 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, documented: { body: D.obj({ name: D.str, url: D.str.describe('https:// address'), events: z.array(D.str) }), response: webhook.extend({ secret: D.str.describe('For checking signatures — shown only now') }) }, 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, documented: { response: delivered }, v1: hooks.resend });
|
||||
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr, events: z.array(D.str).optional(), isActive: D.optBool }), response: webhook }, v1: hooks.update });
|
||||
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, documented: { response: D.ok }, 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, documented: { response: D.obj({ secret: D.str.describe('Shown only now; the old one stops working') }) }, v1: hooks.rotateSecret });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, documented: { response: delivered }, 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', 'status', 'before'), documented: { response: z.array(D.obj({ id: D.str, eventId: D.str, type: D.str.describe('The event type'), payload: z.unknown(), status: D.str.describe('pending, sending, retrying, succeeded, failed'), attempts: D.num, nextAttemptAt: D.optDate, lastStatusCode: D.optNum, lastError: D.optStr, createdAt: D.date, deliveredAt: D.optDate })) }, v1: hooks.deliveries });
|
||||
|
||||
// ─── Backups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', documented: { response: z.array(D.obj({ filename: D.str, size: D.num.describe('Bytes'), createdAt: D.date })) }, v1: backups.getBackups });
|
||||
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, documented: { response: D.anyObj.describe('The backup made') }, 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', documented: { response: D.obj({ secretKeyMasked: D.str, secretKeySource: D.optStr.describe('settings or env'), mode: D.optStr.describe('test or live'), webhookSecretMasked: D.str, webhookSecretSource: D.optStr, webhookId: D.str, webhookUrl: D.str, configured: D.bool }) }, v1: yoco.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, documented: { body: D.obj({ secretKey: D.optStr.describe('sk_test_… or sk_live_…'), webhookSecret: D.optStr.describe('whsec_…') }), response: D.message }, v1: yoco.updateConfig });
|
||||
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', documented: { response: D.obj({ webhooks: z.array(D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr, isActive: D.bool, pointsHere: D.bool })), defaultUrl: D.str }) }, v1: yoco.getWebhooks });
|
||||
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr.describe('Default: this site\'s webhook address') }), response: D.obj({ message: D.str, webhook: D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr }), webhookSecretMasked: D.str }) }, 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) }), documented: { response: D.obj({ message: D.str, clearedSecret: D.bool }) }, v1: yoco.removeWebhook });
|
||||
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', documented: { response: D.obj({ merchantId: D.str, merchantKey: D.str, passphraseMasked: D.str, hasPassphrase: D.bool, mode: z.enum(['sandbox', 'live']), notifyUrl: D.str, notifyUrlProblem: D.optStr.describe('Why PayFast couldn\'t reach it'), configured: D.bool }) }, v1: payfast.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, documented: { body: D.obj({ merchantId: D.optStr, merchantKey: D.optStr, passphrase: D.optStr.describe('At most 32 characters'), mode: z.enum(['sandbox', 'live']).optional() }), response: D.message }, v1: payfast.updateConfig });
|
||||
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, documented: { response: D.message }, v1: payfast.testConnection });
|
||||
@@ -7,6 +7,7 @@
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
import * as D from '../docs';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
@@ -25,114 +26,148 @@ const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.param
|
||||
const loose = z.looseObject({});
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
|
||||
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
|
||||
|
||||
const usage = D.obj({ id: D.str.optional(), scannedAt: D.date, quantityRedeemed: D.num, sessionId: D.optStr, manual: D.optBool.describe('Ticked present by hand') });
|
||||
const ticketFull = D.ticket.extend({
|
||||
usages: z.array(usage).optional().describe('Check-ins'),
|
||||
event: D.event.partial().nullish(),
|
||||
user: D.userSummary.nullish().describe('The payer'),
|
||||
attendee: D.obj({ name: D.str }).nullish(),
|
||||
registrationOption: D.obj({ eventOption: D.eventOption.partial().nullish(), variant: D.obj({ name: D.str }).nullish(), registration: D.registration.partial().nullish() }).nullish(),
|
||||
});
|
||||
const sent = D.obj({ message: D.str, ticketIds: D.ids.optional().describe('The tickets sent') });
|
||||
const lockView = D.obj({
|
||||
id: D.str, eventId: D.str, sectionId: D.optStr, sectionName: D.optStr, deviceId: D.str, deviceName: D.optStr,
|
||||
user: D.userRef.nullish().describe('Who holds it'), lockedAt: D.date, snapshotAt: D.optDate, lastSyncAt: D.optDate, releasedAt: D.optDate,
|
||||
});
|
||||
const transferInput = D.obj({
|
||||
name: D.str.describe('Who it goes to'), email: D.optStr, phone: D.optStr.describe('Email or phone needed'),
|
||||
dateOfBirth: D.optDate.describe('Youth events'), gender: D.optStr, parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr,
|
||||
});
|
||||
const certificate = D.obj({ id: D.str, code: D.str.describe('For checking it at /verify'), name: D.str, issuedAt: D.date, sessionsAttended: D.optNum, totalSessions: D.optNum, event: D.eventRef.partial().nullish() });
|
||||
const registerRow = D.obj({
|
||||
ticketId: D.str, bookedSessions: D.ids.nullable().describe('Picked/ticked sessions (null: all)'), sessionTickets: z.record(z.string(), z.string()).nullable(),
|
||||
name: D.str, bookedBy: D.str, email: D.str, phone: D.str, option: D.str, variant: D.optStr, quantity: D.num,
|
||||
checkIns: z.record(z.string(), z.number()).describe('Session id (or `event`) → people checked in'),
|
||||
attended: D.num, total: D.num, completed: D.bool.describe('Meets the certificate rule'), certificate: D.anyObj.nullish(),
|
||||
});
|
||||
const groupInput = D.obj({
|
||||
name: D.optStr, capacity: D.numeric.nullish(), gender: z.enum(['male', 'female', 'mixed']).nullish(), leaderUserId: D.optStr.describe('A staff member'),
|
||||
minAge: D.numeric.nullish(), maxAge: D.numeric.nullish(), ratio: D.numeric.nullish().describe('Children per leader'),
|
||||
});
|
||||
const group = D.obj({ id: D.str, setId: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leaderUserId: D.optStr, minAge: D.optNum, maxAge: D.optNum, ratio: D.optNum, order: D.num });
|
||||
const allocationSet = D.obj({ id: D.str, eventId: D.str, name: D.str, showOnTicket: D.bool, order: D.num });
|
||||
const member = D.obj({ attendeeId: D.str.optional(), name: D.str, gender: D.optStr, dateOfBirth: D.optDate, groupId: D.optStr });
|
||||
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: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', documented: { response: z.array(ticketFull) }, 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,
|
||||
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), documented: { response: sent }, 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,
|
||||
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), documented: { response: z.array(D.obj({ id: D.str, scannedAt: D.date, quantityRedeemed: D.num, scannedBy: D.userRef.nullish(), ticket: D.obj({ id: D.str, event: D.obj({ title: D.str }), registrationOption: D.anyObj.nullish() }) })) }, 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,
|
||||
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), documented: { response: D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.obj({ scannedById: D.str, name: D.str, count: D.num })) }) }, 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,
|
||||
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, documented: { body: D.obj({ registrationId: D.optStr, ticketIds: D.ids.optional().describe('Or registrationId'), channel: D.channel, overrideEmail: D.optStr.describe('Send here instead'), overridePhone: D.optStr }), response: sent }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), documented: { response: D.obj({ message: D.str, tickets: z.array(ticketFull) }) }, v1: t.generateTickets,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, v1: t.markTicketsAsEmailSent,
|
||||
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, documented: { body: D.obj({ ticketIds: D.ids }), response: D.message }, 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,
|
||||
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), documented: { response: D.v1Page(ticketFull) }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), documented: { response: ticketFull }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(ticketFull) }, 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,
|
||||
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ status: D.str.describe('pending, accepted, declined, cancelled, expired'), toName: D.str, expiresAt: D.date, fromName: D.str, event: D.event.partial(), option: D.str }) }, 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: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.obj({ message: D.str, ticketId: D.str }) }, v1: t.acceptTransferOffer });
|
||||
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.message }, 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,
|
||||
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: ticketFull }, 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,
|
||||
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, documented: { body: transferInput, response: D.obj({ message: D.str, requestId: D.str, expiresAt: D.date }) }, 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,
|
||||
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: D.message }, 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,
|
||||
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: z.array(D.obj({ id: D.str, ticketId: D.str, fromUserId: D.optStr, toUserId: D.optStr, createdAt: D.date })) }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), documented: { response: z.array(lockView) }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, sectionId: D.optStr.describe('Just this section (default: the whole event)'), deviceId: D.str, deviceName: D.optStr }), response: lockView }, 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,
|
||||
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() }), documented: { response: z.array(D.obj({ id: D.str, accepted: D.optNum, duplicates: D.optNum, conflicts: z.unknown().optional(), reviewedAt: D.optDate, createdAt: D.date, scanLock: lockView })) }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, documented: { response: D.obj({ id: D.str, reviewedAt: D.date, reviewedById: D.str }) }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), documented: { response: D.obj({ lock: lockView, snapshotAt: D.date, session: D.obj({ id: D.str, label: D.str }).nullable(), noSessionToday: D.bool, tickets: z.array(D.obj({ id: D.str, qrCode: D.str, holder: D.str, bookedBy: D.str, option: D.str, variant: D.optStr, quantity: D.num, remaining: D.num, consentStatus: D.str })) }) }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { body: D.obj({ deviceId: D.str, scans: z.array(D.obj({ clientScanId: D.str.describe('The device\'s id for the scan (repeats are ignored)'), qrCode: D.str, qty: D.numeric.optional(), scannedAt: D.optDate, sessionId: D.optStr })).max(2000) }), response: D.obj({ accepted: D.num, duplicates: D.num, conflicts: z.array(D.anyObj).describe('Scans that clashed with online check-ins'), reportId: D.str, released: D.bool }) }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { response: D.message }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), documented: { response: D.message }, 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,
|
||||
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), documented: { response: D.obj({ valid: D.bool, revoked: D.optBool, name: D.optStr, event: D.optStr, completedOn: D.optDate, issuedAt: D.optDate }) }, v1: att.verifyCertificate,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', v1: att.myCertificates });
|
||||
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', documented: { response: z.array(certificate) }, 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,
|
||||
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' }, documented: { body: D.obj({ title: D.optStr.describe('Event title to show'), config: D.obj({ template: D.optStr.describe('classic, modern, elegant, simple'), title: D.optStr, body: D.optStr.describe('{name} and {event} are filled in') }).optional() }) }, v1: att.previewCertificate,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
|
||||
@@ -140,11 +175,11 @@ v1Route({
|
||||
});
|
||||
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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, documented: { response: D.ok }, 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,
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.event.partial().extend({ certificatesEnabled: D.optBool, certificateConfig: D.anyObj.nullish() }), columns: z.array(D.obj({ id: D.str.describe('Session id, or `event`'), label: D.str })), rows: z.array(registerRow), totals: z.record(z.string(), z.number()) }) }, v1: att.getRegister,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
|
||||
@@ -153,15 +188,15 @@ v1Route({
|
||||
});
|
||||
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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.str, sessionId: D.str.describe('A session id, or `event` on a single-date event'), present: D.bool }), response: D.ok }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.optStr.describe('Just this person (default: everyone who qualifies)') }), response: D.obj({ issued: D.num }) }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ certificateId: D.optStr.describe('Just this one (default: every one not sent yet)') }), response: D.obj({ sent: D.num, skipped: D.num }) }, v1: att.sendCertificatesHandler,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
|
||||
@@ -172,24 +207,24 @@ v1Route({
|
||||
|
||||
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,
|
||||
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], documented: { response: D.obj({ medicalShown: D.bool, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, setName: D.str, event: D.eventRef.partial(), members: z.array(member) })) }) }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, sets: z.array(allocationSet.extend({ groups: z.array(group), assignments: z.array(D.obj({ attendeeId: D.str, groupId: D.str })) })), attendees: z.array(D.obj({ id: D.str, name: D.str, gender: D.optStr, dateOfBirth: D.optDate, registrationId: D.str, bookedBy: D.str })), leaders: z.array(D.userRef), unnamedPlaces: D.num.describe('Places with no name yet') }) }, 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,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, name: D.str.describe('e.g. Cabins, Buses'), showOnTicket: D.optBool }), response: allocationSet }, 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 });
|
||||
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, showOnTicket: D.optBool }), response: allocationSet }, 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, documented: { response: D.message }, 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'), documented: { response: D.obj({ set: D.obj({ id: D.str, name: D.str }), event: D.anyObj, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leader: D.optStr, members: z.array(member) })) }) }, 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, documented: { body: groupInput, response: group }, 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, documented: { body: D.obj({ attendeeId: D.str, groupId: D.optStr.describe('null takes them out') }), response: D.message }, 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, documented: { response: D.message }, 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, documented: { body: D.obj({ mode: z.enum(['together', 'split']).optional().describe('Keep each booking together, or spread them') }), response: D.obj({ placed: D.num, unplaced: D.num, message: D.str }) }, 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, documented: { body: groupInput, response: group }, 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, documented: { response: D.message }, v1: alloc.deleteGroup });
|
||||
|
||||
@@ -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) }; },
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import * as D from '../docs';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { single } from '../paging';
|
||||
@@ -148,6 +149,7 @@ export const copyFrom = defineRoute({
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
|
||||
params: idParams,
|
||||
body: z.object({ fromEventId: id }),
|
||||
documented: { response: D.obj({ sets: D.num.describe('Allocation sets copied'), groups: D.num.describe('Groups copied'), costs: D.num.describe('Costs copied (admins)') }) },
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ params, body, user }) {
|
||||
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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';
|
||||
import * as D from '../docs';
|
||||
|
||||
/* 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' })] };
|
||||
|
||||
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
|
||||
|
||||
const child = D.obj({
|
||||
id: D.str, firstName: D.str, lastName: D.str, name: D.str, dateOfBirth: D.optDate, age: D.optNum, gender: D.optStr,
|
||||
allergies: D.optStr, hasMedical: D.bool, hasCustody: D.bool.describe('There are custody notes'),
|
||||
medicalNotes: D.optStr.describe('Only where notes may be shown'), custodyNotes: D.optStr, lastCheckInAt: D.optDate,
|
||||
});
|
||||
const childInput = D.obj({
|
||||
firstName: D.str, lastName: D.optStr, dateOfBirth: D.optDate, gender: D.optStr.describe('male or female (anything else: not given)'),
|
||||
allergies: D.optStr, medicalNotes: D.optStr, custodyNotes: D.optStr.describe('Kept encrypted, like the medical notes'),
|
||||
});
|
||||
const adultInput = D.obj({ name: D.str, phone: D.optStr, relationship: D.optStr });
|
||||
const adult = adultInput.extend({ id: D.str });
|
||||
const family = D.obj({
|
||||
id: D.str, name: D.str,
|
||||
parents: z.array(D.obj({ userId: D.str, name: D.str, phone: D.optStr, email: D.optStr })),
|
||||
children: z.array(child), adults: z.array(adult).describe('Others who may collect the children'),
|
||||
});
|
||||
const myFamily = D.obj({
|
||||
family: family.nullable(), qrToken: D.optStr.describe('The family QR code (shown at the desk)'),
|
||||
codes: z.array(D.anyObj).describe('Collection codes for children checked in now'), pages: z.array(D.anyObj).describe('Recent "come to the room" messages'),
|
||||
});
|
||||
const parentInput = D.obj({ name: D.str, email: D.optStr, phone: D.optStr.describe('Email or phone needed: the collection code is sent there') });
|
||||
const room = D.obj({ id: D.str, name: D.str, minAge: D.optNum, maxAge: D.optNum, capacity: D.optNum, ratio: D.optNum, order: D.optNum, leaderNames: z.array(D.str).optional(), leaderIds: D.ids.optional() });
|
||||
const board = D.obj({
|
||||
event: D.anyObj, occasion: D.str.describe('The day or session it\'s for'), sessionId: D.optStr, sessionLabel: D.optStr,
|
||||
rooms: z.array(room), checkIns: z.array(D.anyObj).describe('Children checked in, with room and code'),
|
||||
});
|
||||
const roleShift = D.obj({ startsAt: D.date, endsAt: D.date, text: D.str, hours: D.num });
|
||||
const role = D.obj({
|
||||
id: D.str, eventId: D.str, sessionId: D.optStr, sessionLabel: D.optStr, name: D.str, description: D.str,
|
||||
startsAt: D.optDate, endsAt: D.optDate, shift: roleShift, kidsRoom: D.anyObj.nullish(), order: D.num, past: D.bool,
|
||||
needed: D.optNum, filled: D.optNum,
|
||||
mine: D.obj({ slotId: D.str, checkedInAt: D.optDate, swapId: D.optStr }).nullable().describe('My place in it'),
|
||||
coverNeeded: z.array(D.obj({ swapId: D.str, note: D.str })),
|
||||
volunteers: z.array(D.obj({ slotId: D.str, userId: D.str, name: D.str, phone: D.optStr, email: D.optStr, assignedBy: D.optStr, checkedInAt: D.optDate, checkedInBy: D.optStr, checkInMethod: D.optStr, swapOpen: D.bool, hasTicket: D.bool })).optional().describe('Staff only'),
|
||||
});
|
||||
const roster = D.obj({
|
||||
event: D.anyObj, sessions: z.array(D.obj({ id: D.str, label: D.str, startsAt: D.date, endsAt: D.date })), roles: z.array(role),
|
||||
totals: D.obj({ needed: D.optNum, filled: D.optNum, fillRate: D.optNum.describe('%') }),
|
||||
});
|
||||
const roleInput = D.obj({
|
||||
name: D.str, description: D.optStr, sessionId: D.optStr.describe('A multi-day event: the session'), startsAt: D.optDate, endsAt: D.optDate.describe('Default: the event\'s or session\'s times'),
|
||||
needed: D.numeric.nullish().describe('How many people'), kidsRoomId: D.optStr.describe('Serving in a kids room: counts as a leader on duty'), order: D.numeric.optional(),
|
||||
});
|
||||
const roleRecord = D.obj({ id: D.str, eventId: D.str, name: D.str, description: D.optStr, sessionId: D.optStr, startsAt: D.optDate, endsAt: D.optDate, needed: D.optNum, kidsRoomId: D.optStr, order: D.num });
|
||||
const signedUp = D.obj({ slotId: D.str, ticket: D.bool.describe('A free volunteer ticket was issued') });
|
||||
|
||||
// ─── 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', documented: { response: myFamily }, v1: kids.getMine });
|
||||
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, documented: { body: D.obj({ name: D.optStr }), response: myFamily }, v1: kids.createMine });
|
||||
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, documented: { body: D.obj({ name: D.str }), response: family }, v1: kids.renameMine });
|
||||
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', documented: { response: D.anyObj.describe('What happened: left, or the family was deleted as the last parent') }, v1: kids.leaveMine });
|
||||
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, documented: { body: childInput, response: child }, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, documented: { body: childInput.partial(), response: child }, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, documented: { response: D.message }, 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, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, documented: { response: D.message }, 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], documented: { response: z.array(D.event.partial().extend({ kidsCheckIn: D.optBool })) }, 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], documented: { response: z.array(D.event.partial()) }, 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, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, scheduleType: D.optStr, kidsCheckIn: D.bool, kidsOptionId: D.optStr, kidsRoomSetId: D.optStr }), options: z.array(D.eventOption.partial()), sets: z.array(D.anyObj).describe('Allocation sets that can be the rooms') }) }, 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, documented: { body: D.obj({ enabled: D.optBool, optionId: D.optStr.describe('The kids ticket (`new` makes a free one; null: none)'), roomSetId: D.optStr.describe('The allocation set used as rooms') }).describe('Only what changes'), response: D.anyObj.describe('The saved set-up, as GET answers') }, 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, documented: { response: board }, 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, documented: { response: z.array(family) }, 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, documented: { body: D.obj({ name: D.optStr, parent: parentInput, children: z.array(childInput).max(12).optional() }), response: family }, 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, documented: { response: family }, 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, documented: { response: family }, 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, documented: { body: parentInput, response: family }, 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, documented: { body: childInput, response: child }, 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, documented: { body: childInput.partial(), response: child }, 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, documented: { body: adultInput, response: family }, 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, documented: { body: D.obj({ familyId: D.str, sessionId: D.optStr, children: z.array(D.obj({ childId: D.str, roomId: D.optStr })).describe('Who, and into which room (default: by age)'), sendToAdultId: D.optStr.describe('Send the code to this adult too') }), response: D.obj({ code: D.str.describe('The collection code'), qr: D.str, occasion: D.str, family: D.obj({ id: D.str, name: D.str }), checkIns: z.array(D.obj({ id: D.str, name: D.str, firstName: D.str, room: D.anyObj.nullish(), already: D.bool, sticker: D.optStr.describe('Write this on the sticker') })) }) }, 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, documented: { body: D.obj({ code: D.str, checkInIds: D.ids.optional().describe('Just these children (default: all on the code)'), collectedBy: D.optStr.describe('Who collected them'), roomId: D.optStr, sessionId: D.optStr }), response: D.obj({ checkedOut: z.array(D.obj({ id: D.str, name: D.str, room: D.optStr })) }) }, 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, documented: { response: D.obj({ code: D.str, found: D.bool, family: D.anyObj.nullish(), children: z.array(D.obj({ id: D.str, name: D.str, room: D.anyObj.nullish(), checkedOutAt: D.optDate, collectedBy: D.optStr })) }) }, 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, documented: { body: D.obj({ roomId: D.str, sessionId: D.optStr, on: D.optBool.describe('false: off duty') }), response: D.obj({ roomId: D.str, on: D.bool }) }, 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, documented: { response: board.extend({ families: z.array(family), codes: z.array(D.anyObj).describe('Codes reserved for this device'), downloadedAt: D.date }) }, 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, documented: { body: D.obj({ deviceId: D.str, sessionId: D.optStr, checkIns: z.array(D.anyObj).optional(), checkOuts: z.array(D.anyObj).optional() }), response: D.obj({ checkedIn: D.num, checkedOut: D.num, conflicts: z.array(D.anyObj) }) }, 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, documented: { body: D.obj({ reason: D.str, collectedBy: D.str }), response: D.obj({ id: D.str, checkedOutAt: D.date }) }, 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, documented: { body: D.obj({ message: D.optStr }), response: D.obj({ sent: z.array(D.str).describe('Channels used'), text: D.str }) }, 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, documented: { body: D.obj({ roomId: D.str }), response: D.obj({ id: D.str, roomId: D.str }) }, 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, documented: { response: D.obj({ resolved: D.num }) }, v1: kids.resolveAlert });
|
||||
|
||||
// ─── Volunteering: mine ──────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', documented: { response: D.obj({ slots: z.array(D.obj({ slotId: D.str, role: D.anyObj, event: D.anyObj, shift: roleShift, checkedInAt: D.optDate, canCheckIn: D.bool, past: D.bool, swapId: D.optStr, hasTicket: D.bool })), cover: z.array(D.anyObj).describe('Slots others want covered'), events: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, location: D.optStr, roles: D.num })) }) }, 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, documented: { response: roster }, 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, documented: { response: signedUp }, v1: vol.signUp });
|
||||
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, documented: { response: D.message }, 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, documented: { body: D.obj({ note: D.optStr }), response: D.obj({ swapId: D.str }) }, v1: vol.requestSwap });
|
||||
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, documented: { response: D.obj({ cancelled: z.literal(true) }) }, v1: vol.cancelSwap });
|
||||
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, documented: { response: D.obj({ slotId: D.str, role: D.str, event: D.str }) }, 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, documented: { body: D.obj({ qr: D.str.describe('What the QR code holds') }), response: D.obj({ event: D.obj({ id: D.str, title: D.str }), checkedIn: z.array(D.obj({ role: D.str, kidsRoom: D.optStr })), already: z.array(D.str) }) }, 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'), documented: { response: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, scheduleType: D.optStr, campusId: D.optStr, roles: D.num })) }, 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'), documented: { response: z.array(D.obj({ userId: D.str.optional(), name: D.str, hours: D.num, shifts: D.optNum })) }, 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, documented: { response: roster }, 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, documented: { response: D.obj({ token: D.str, qr: D.str.describe('Put this in the QR code') }) }, v1: vol.getQr });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, volunteerOptionId: D.optStr }), options: z.array(D.eventOption.partial()), kidsRooms: z.array(D.anyObj) }) }, 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, documented: { body: D.obj({ optionId: D.optStr.describe('The free volunteer ticket (null: none)') }), response: D.anyObj.describe('The saved set-up, as GET answers') }, 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, documented: { body: roleInput, response: roleRecord }, 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, documented: { body: roleInput.partial(), response: roleRecord }, v1: vol.updateRole });
|
||||
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, documented: { response: D.obj({ message: D.str, volunteers: D.num.describe('People who were in it (told by message)') }) }, 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, documented: { body: D.obj({ userId: D.str }), response: signedUp }, 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, documented: { body: D.obj({ undo: D.optBool }), response: D.obj({ slotId: D.str, changed: D.bool }) }, 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, documented: { response: D.message }, v1: vol.remove });
|
||||
@@ -25,20 +25,40 @@ const toMe = (u: MeRow): z.input<typeof me> => ({
|
||||
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { requiresTwoFactor } = require('../../utils/twoFactor') as { requiresTwoFactor(user: object): boolean };
|
||||
const privacyNotice = require('../../utils/privacyNotice') as { noticePromptPending(u: { noticeVersionAccepted: unknown }): Promise<false | 'first' | 'updated'> };
|
||||
const campusScope = require('../../utils/campusScope') as { scopeFor(req: unknown): Promise<{ campusIds: string[] | null } | null> };
|
||||
|
||||
export const getMe = defineRoute({
|
||||
method: 'get',
|
||||
path: '/me',
|
||||
tag: 'Me',
|
||||
summary: 'The signed-in person',
|
||||
description: 'With what the app asks after sign-in (`newsPromptPending`, `noticePromptPending`), whether two-step sign-in is required for their role, and the campuses they\'re limited to (`campusScope`, null = every campus). Viewing as someone else never prompts.',
|
||||
access: 'user',
|
||||
response: single(me),
|
||||
async handler({ user }) {
|
||||
response: single(me.extend({
|
||||
twoFactorRequired: z.boolean().describe('Their role must use two-step sign-in'),
|
||||
newsPromptPending: z.boolean(),
|
||||
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]),
|
||||
campusScope: z.array(id).nullable(),
|
||||
})),
|
||||
async handler({ user, req }) {
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user!.id },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
|
||||
});
|
||||
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
|
||||
return { data: toMe(u) };
|
||||
const impersonating = !!(req as any).impersonation;
|
||||
return {
|
||||
data: {
|
||||
...toMe(u),
|
||||
twoFactorRequired: requiresTwoFactor(u),
|
||||
newsPromptPending: !u.newsAskedAt && !impersonating,
|
||||
noticePromptPending: impersonating ? false as const : await privacyNotice.noticePromptPending({ noticeVersionAccepted: u.noticeVersionAccepted }),
|
||||
campusScope: (await campusScope.scopeFor(req))?.campusIds ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { listData, v1Route } from '../viaV1';
|
||||
import * as D from '../docs';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
@@ -33,38 +34,88 @@ const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.param
|
||||
// Links in messages are credentials, so guessing stays slow (as on v1).
|
||||
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
|
||||
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
|
||||
|
||||
const providerRow = D.obj({ id: D.str.describe('yoco, payfast'), label: D.str });
|
||||
const providerSettings = D.obj({
|
||||
id: D.str, label: D.str, enabled: D.bool, configured: D.bool.describe('Its keys are set up'),
|
||||
capabilities: D.anyObj.describe('What the provider can do, e.g. look payments up'),
|
||||
});
|
||||
const openCheckout = D.obj({
|
||||
id: D.str, provider: D.str, providerLabel: D.str, canCheck: D.bool.describe('The provider can be asked whether it was paid'),
|
||||
amount: D.rand, description: D.str, type: D.optStr.describe('registration, donation, sponsorship, season_pass…'),
|
||||
registrationId: D.optStr, payer: D.optStr, createdAt: D.date,
|
||||
});
|
||||
const payPage = D.obj({
|
||||
id: D.str, status: z.enum(['open', 'paid']), amount: D.rand, description: D.str,
|
||||
provider: D.optStr.describe('The provider already chosen, if any'), providers: z.array(providerRow), cancelUrl: D.optStr,
|
||||
redirectUrl: D.optStr, form: D.anyObj.nullish().describe('A signed form to post (PayFast)'),
|
||||
});
|
||||
const paymentFull = D.payment.extend({
|
||||
registration: D.registration.nullish(),
|
||||
generatedTickets: z.array(D.ticket).optional().describe('Tickets issued because this paid it in full'),
|
||||
formsNeeded: D.optNum.describe('Attendee forms still holding the tickets back (1.52.0)'),
|
||||
donationSplit: D.payment.optional().describe('What was paid over the balance, kept as a donation'),
|
||||
});
|
||||
const cashupLineInput = D.obj({
|
||||
method: D.str.describe('cash, card, eft…'),
|
||||
bankAmount: D.optRand, onHandAmount: D.optRand.describe('Cash only'), pendingAmount: D.optRand.describe('Not cash'), pendingReference: D.optStr,
|
||||
writeOffs: z.array(D.obj({ category: D.str, amount: D.optRand, reason: D.optStr })).optional(), notes: D.optStr,
|
||||
});
|
||||
const cashupRecord = D.obj({ id: D.str, eventId: D.str, action: D.optStr.describe('close, quick_close, reopen'), notes: D.optStr, createdAt: D.date, performedBy: D.userRef.nullish() });
|
||||
const providerTransaction = D.obj({
|
||||
id: D.str, provider: D.str, providerLabel: D.str, externalId: D.str.describe('The provider\'s id'),
|
||||
amount: D.optNum.describe('Cents'), currency: D.optStr, createdDate: D.optDate, checkoutId: D.optStr, methodType: D.optStr,
|
||||
reconciled: D.bool, ignored: D.bool, paymentId: D.optStr, note: D.optStr.describe('Why it needs a look'), metadata: D.anyObj.nullish(),
|
||||
});
|
||||
const txAnswer = <T extends z.ZodType>(data: T) => D.obj({ success: D.bool, data, message: D.optStr });
|
||||
const waitlistEntry = D.obj({
|
||||
id: D.str, eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr, userId: D.str,
|
||||
quantity: D.num, position: D.num.describe('Place in the queue'), status: D.str.describe('waiting, offered, claimed, expired, removed, left'),
|
||||
offeredQuantity: D.optNum, offeredAt: D.optDate, offerExpiresAt: D.optDate, claimedAt: D.optDate, registrationId: D.optStr, createdAt: D.date,
|
||||
});
|
||||
const consentRow = D.obj({
|
||||
name: D.str.describe('The young person'), status: D.str.describe('not_requested, pending, given, declined'),
|
||||
parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr, request: D.anyObj.nullish(),
|
||||
});
|
||||
const feedbackField = D.obj({ id: D.str.optional(), type: D.str.describe('rating, multiple_choice, text, yes_no…'), label: D.str, isRequired: D.optBool, helpText: D.optStr, options: z.array(D.str).nullish() });
|
||||
const feedbackForm = D.obj({
|
||||
id: D.str, eventId: D.str, sendAt: D.optDate, sentAt: D.optDate, audience: D.str.describe('checked_in or everyone'), anonymous: D.bool, intro: D.optStr,
|
||||
fields: z.array(feedbackField), invited: D.optNum, responses: z.unknown().optional().describe('Answers per question'),
|
||||
}).nullable().describe('null when the event has no feedback form');
|
||||
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
|
||||
|
||||
// ─── Payments ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
|
||||
access: 'public', v1: providers.listPaymentProviders,
|
||||
access: 'public', documented: { response: D.obj({ providers: z.array(providerRow), minimumAmount: D.rand.describe('Smallest online payment') }) }, v1: providers.listPaymentProviders,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
|
||||
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
|
||||
description: 'Admins. Keys are masked.', access: 'admin', documented: { response: D.obj({ providers: z.array(providerSettings), minimumAmount: D.rand }) }, v1: providers.getPaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
|
||||
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
|
||||
access: 'admin', body: z.looseObject({}), documented: { body: D.obj({ providers: z.array(D.obj({ id: D.str, enabled: D.bool })).min(1).describe('Every provider, in the order to offer them') }), response: D.obj({ message: D.str, providers: z.array(providerSettings) }) }, v1: providers.updatePaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: D.obj({ checkouts: z.array(openCheckout), days: D.num.describe('How far back it looks') }) }, v1: providers.listOpenCheckouts,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
|
||||
description: 'Records any that were paid (for when a webhook was missed).',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), documented: { body: D.obj({ ids: D.ids.optional().describe('Checkouts to ask about (default: every open one), at most 200') }), response: D.obj({ results: z.array(D.anyObj), recorded: D.num.describe('Payments found and recorded'), message: D.str }) }, v1: providers.checkCheckoutStatus,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
|
||||
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, documented: { response: D.obj({ totalToday: D.rand, totalWeek: D.rand, totalMonth: D.rand }) }, v1: pay.getPaymentStats,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
|
||||
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
|
||||
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
|
||||
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
|
||||
@@ -72,129 +123,129 @@ v1Route({
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
|
||||
v1: pay.createPayment,
|
||||
documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('A donation: the event'), amount: D.numeric, method: D.str.describe('cash, card, eft or other'), isDonation: D.optBool, paidAt: D.optDate.describe('Backdate it'), userId: D.optStr.describe('A donation: who gave it') }), response: paymentFull }, v1: pay.createPayment,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
|
||||
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
|
||||
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), documented: { body: D.obj({ paymentId: D.optStr.describe('The payment refunded (or registrationId)'), registrationId: D.optStr, userId: D.str, amount: D.numeric.describe('Positive; stored as a negative payment'), method: D.str, reason: D.optStr }), response: D.payment }, v1: pay.createRefund,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
|
||||
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
|
||||
body: z.looseObject({ paymentId: id, registrationId: id }), documented: { body: D.obj({ paymentId: D.str.describe('The donation'), registrationId: D.str, amount: D.numeric.optional().describe('Default: as much as both allow') }), response: D.obj({ message: D.optStr, updatedRegistration: D.registration.optional(), generatedTickets: z.array(D.ticket).optional() }) }, v1: pay.assignDonationToRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
|
||||
body: z.looseObject({ legId: id }), documented: { response: D.obj({ message: D.str, updatedRegistration: D.registration, donationId: D.str }) }, v1: pay.unassignDonationFromRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
|
||||
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
|
||||
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
|
||||
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), documented: { body: D.obj({ eventId: D.str, amount: D.numeric, provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ amount: D.rand, event: D.eventRef.partial() }) }, v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
|
||||
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getUserPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, documented: { response: D.payment.extend({ registration: D.registration.nullish() }) }, v1: pay.getPaymentById,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
|
||||
body: z.looseObject({ channel: z.string().max(20).optional() }), documented: { response: D.message }, v1: pay.sendReceipt,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
|
||||
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
|
||||
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
|
||||
response: listData, documented: { response: z.array(D.payment) }, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
|
||||
description: 'Supervisors: a checkout for `amount` (part of what\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,
|
||||
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ amount: D.numeric.optional(), donationAmount: D.numeric.optional(), provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ priceUpdated: D.optBool, newTotal: D.optRand }) }, v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
|
||||
description: 'By email and/or WhatsApp, for what\'s still owing.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
|
||||
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ redirectUrl: D.str.describe('The link to send'), channel: z.enum(['email', 'whatsapp']) }), response: D.obj({ sent: z.literal(true), channel: D.str }) }, v1: pay.sendPaymentLink,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(D.payment) }, v1: pay.getPaymentsByEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
|
||||
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
|
||||
access: 'public', params: idParams, v1: providers.getPayPage,
|
||||
access: 'public', params: idParams, documented: { response: payPage }, v1: providers.getPayPage,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
|
||||
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
|
||||
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
|
||||
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), documented: { response: D.obj({ provider: D.str, redirectUrl: D.optStr, form: D.anyObj.nullish() }) }, v1: providers.choosePayProvider,
|
||||
});
|
||||
|
||||
// ─── Cash-ups ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cashupBase = '/events/:id/cashup';
|
||||
v1Route({
|
||||
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
|
||||
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), documented: { response: z.array(cashupRecord) }, v1: cashup.getCashupAudit,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
|
||||
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, paymentsByMethod: D.anyObj.describe('Income per method'), totalRevenue: D.rand, costs: z.array(D.anyObj), totalCosts: D.rand, expectedCashByMethod: D.anyObj, reconciled: D.anyObj.nullish().describe('The last close, line by line'), netProfit: D.rand, totalDonations: D.rand, unallocatedDonationsTotal: D.rand, discountsTotal: D.optRand, salesByOption: z.array(D.anyObj).optional() }) }, v1: cashup.getEventCashup,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ rows: z.array(D.anyObj).describe('Per person: who, cash recorded, cash counted'), cashActualTotal: D.optRand, cashDenominations: D.anyObj.nullish() }) }, v1: cashup.getCashByRecordedUser,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), documented: { body: D.obj({ denominations: z.array(D.obj({ value: D.num.describe('The note or coin, in rand'), count: D.num })), notes: D.optStr }), response: D.anyObj.describe('The saved count') }, v1: cashup.savePersonCash,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput), notes: D.optStr }), response: D.message }, v1: cashup.saveEventCashupDraft,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
|
||||
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput).optional().describe('Per payment method: in the bank, on hand, pending, write-offs (leave out for a quick close)'), notes: D.optStr }), response: cashupRecord }, v1: cashup.closeEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ notes: D.optStr.describe('Why') }), response: cashupRecord }, v1: cashup.reopenEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), documented: { body: D.obj({ outcome: z.enum(['received', 'written_off']), amount: D.numeric, category: D.optStr.describe('A write-off: what it was for'), notes: D.optStr }), response: D.anyObj.describe('The settlement recorded') }, v1: cashup.resolvePending,
|
||||
});
|
||||
|
||||
// ─── Provider transactions ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getAllProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getUnreconciledProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('As a donation to this event instead'), userId: D.optStr }), response: txAnswer(D.obj({ transaction: providerTransaction, payment: D.payment, generatedTickets: z.array(D.ticket).optional() })) }, v1: ptx.reconcileProviderTransaction,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { response: txAnswer(providerTransaction) }, v1: ptx.ignoreProviderTransaction,
|
||||
});
|
||||
|
||||
// ─── Waitlist ────────────────────────────────────────────────────────────────
|
||||
@@ -202,73 +253,78 @@ v1Route({
|
||||
v1Route({
|
||||
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
|
||||
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr.describe('Per-session tickets: the session'), quantity: D.numeric, guestName: D.optStr.describe('Not signed in'), guestEmail: D.optStr, guestPhone: D.optStr }), response: waitlistEntry.extend({ ahead: D.num.describe('People ahead'), message: D.str }) }, v1: waitlist.joinWaitlist,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
|
||||
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', documented: { response: z.array(waitlistEntry) }, v1: waitlist.myWaitlist });
|
||||
v1Route({
|
||||
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
|
||||
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ eventId: D.optStr, eventTitle: D.optStr, eventStart: D.optDate, eventOptionId: D.str, optionName: D.str, variantId: D.optStr, variantName: D.optStr, sessionId: D.optStr, sessionName: D.optStr, sessionStart: D.optDate, lineName: D.str, quantity: D.optNum.describe('Places offered'), requested: D.num, expiresAt: D.optDate, name: D.optStr }) }, v1: waitlist.claimInfo,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, documented: { response: z.array(waitlistEntry.extend({ user: D.userSummary.nullish() })) }, v1: waitlist.eventWaitlist,
|
||||
});
|
||||
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
|
||||
v1Route({
|
||||
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
|
||||
documented: {
|
||||
body: action === 'offer' ? D.obj({ quantity: D.numeric.optional().describe('Places to offer (default: all they asked for)') })
|
||||
: action === 'move' ? D.obj({ direction: z.enum(['up', 'down']) }) : D.anyObj,
|
||||
response: D.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
|
||||
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, documented: { response: D.message }, v1: waitlist.leaveWaitlist });
|
||||
|
||||
// ─── Sponsorship ─────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
|
||||
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
|
||||
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, documented: { response: D.obj({ enabled: D.bool, open: D.optBool, spotPrice: D.optRand, showProgress: D.optBool, goalSpots: D.optNum, spotsFunded: D.optNum, sponsors: z.unknown().optional() }) }, v1: sponsor.getEventSponsorship,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
|
||||
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
|
||||
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ eventId: D.str, spotPrice: D.rand, goalSpots: D.optNum }).describe('Plus the pool: given, used and places funded') }, v1: sponsor.getPool,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
|
||||
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
|
||||
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, spots: D.numeric.optional().describe('Places to fund (or amount)'), amount: D.numeric.optional(), anonymous: D.optBool, sponsorName: D.optStr, message: D.optStr, name: D.optStr.describe('Not signed in'), email: D.optStr, phone: D.optStr, provider: D.optStr }), response: D.checkout.extend({ amount: D.rand }) }, v1: sponsor.createSponsorCheckout,
|
||||
});
|
||||
|
||||
// ─── Consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, counts: z.record(z.string(), z.number()).describe('Status → how many'), rows: z.array(consentRow) }) }, v1: consent.listEventConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.eventRef.partial(), rows: z.array(D.obj({ name: D.str, dateOfBirth: D.optDate, parentName: D.optStr, parentPhone: D.optStr, parentEmail: D.optStr, medical: D.anyObj.nullish(), emergency: D.anyObj.nullish(), photoConsent: D.optBool, purged: D.bool.describe('Medical details removed after the event') })) }) }, v1: consent.medicalList,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ message: D.str, sent: D.num, pending: D.num }) }, v1: consent.resendPending,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
|
||||
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), documented: { response: D.obj({ message: D.str, created: D.num }) }, v1: consent.rerequestConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
|
||||
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({}), v1: consent.resendRequest,
|
||||
body: z.looseObject({}), documented: { response: D.message }, v1: consent.resendRequest,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
|
||||
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ status: D.str, superseded: D.bool, expired: D.bool, respondedAt: D.optDate, attendeeName: D.str, parentName: D.optStr, consentText: D.str, textVersion: D.optNum, event: D.obj({ title: D.str, startDate: D.date, endDate: D.date, location: D.optStr }), collect: D.obj({ medical: D.bool, emergency: D.bool, photo: D.bool }).describe('What the form asks for') }) }, v1: consent.getConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ decision: z.enum(['give', 'decline']), signedName: D.str, isGuardian: D.bool.describe('Must be true'), medical: D.obj({ allergies: D.optStr, medication: D.optStr, conditions: D.optStr, medicalAid: D.optStr }).optional(), emergency: D.obj({ name: D.str, phone: D.str, relationship: D.optStr }).optional(), photoConsent: D.optBool }), response: D.obj({ message: D.str, status: z.enum(['given', 'declined']) }) }, v1: consent.answerConsent,
|
||||
});
|
||||
|
||||
// ─── Attendee form answers ───────────────────────────────────────────────────
|
||||
@@ -276,35 +332,35 @@ v1Route({
|
||||
v1Route({
|
||||
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
|
||||
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
|
||||
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
|
||||
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), documented: { response: D.obj({ items: z.array(D.obj({ id: D.str, registrationId: D.str, attendeeId: D.optStr, createdAt: D.date, answers: z.array(D.obj({ fieldId: D.str, value: D.str })).optional() })), nextCursor: D.optStr.describe('Pass as `cursor` for the next page') }) }, v1: forms.listFormResponses,
|
||||
});
|
||||
|
||||
// ─── Feedback ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
|
||||
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), documented: { response: z.array(D.obj({ eventId: D.str, title: D.str, startDate: D.date, anonymous: D.bool, sentAt: D.optDate, invited: D.num, responses: D.num, responseRate: D.optNum.describe('%'), questions: z.array(D.anyObj).describe('Each question with its answers summarised') })) }, v1: feedback.getSummary,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
|
||||
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
|
||||
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ event: D.anyObj, name: D.str, intro: D.optStr, anonymous: D.bool, responded: D.bool, fields: z.array(feedbackField) }) }, v1: feedback.getPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
|
||||
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
|
||||
access: 'public', params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ answers: z.record(z.string(), z.unknown()).describe('Field id → answer') }), response: D.message }, v1: feedback.submitPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: feedbackForm }, v1: feedback.getEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ sendAt: D.optDate, audience: D.optStr.describe('checked_in or everyone'), anonymous: D.optBool, intro: D.optStr, fields: z.array(feedbackField) }), response: feedbackForm }, v1: feedback.saveEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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';
|
||||
import * as D from '../docs';
|
||||
|
||||
/* 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)';
|
||||
|
||||
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
|
||||
|
||||
const challenge = D.obj({ challengeId: D.str, method: z.enum(['email', 'whatsapp']), destination: D.str.describe('Where the code went, partly hidden') });
|
||||
const signedIn = D.obj({ message: D.optStr, id: D.str, name: D.str, email: D.str, role: D.str, token: D.str.describe('A new sign-in token') });
|
||||
const kind = z.enum(['update', 'news']).describe('update: about something they booked (always sent). news: needs their news consent');
|
||||
const attendeeFilter = D.obj({
|
||||
status: D.optStr.describe('any, paid, unpaid, checked_in, not_checked_in…'), attendeeIds: D.ids.optional(), registrationIds: D.ids.optional(),
|
||||
allocationGroupId: D.optStr.describe('People in one room or group'), name: D.optStr, email: D.optStr, phone: D.optStr,
|
||||
audience: z.enum(['sponsors']).optional().describe('Sponsors of the event instead'),
|
||||
});
|
||||
const emailAttendees = D.obj({
|
||||
subject: D.optStr, html: D.optStr, text: D.optStr.describe('subject and html or text, unless template is tickets'),
|
||||
template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional().describe('tickets: send everyone their tickets; the reminders fill in a standard message'), filter: attendeeFilter.optional(),
|
||||
dryRun: D.optBool.describe('Only say who it would reach'), kind: kind.optional(), promoEventId: D.optStr.describe('Promote another event (news)'),
|
||||
});
|
||||
const whatsappAttendees = D.obj({
|
||||
message: D.optStr.describe('Required unless template is tickets'), template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional(), filter: attendeeFilter.optional(),
|
||||
dryRun: D.optBool, kind: kind.optional(), promoEventId: D.optStr,
|
||||
});
|
||||
const reach = D.obj({ eventId: D.optStr, kind: D.optStr, matched: D.num, skipped: D.optNum.describe('Left out: no news consent'), recipients: z.array(D.anyObj).optional().describe('The first 20'), queued: D.optNum, template: D.optStr, sent: D.optNum });
|
||||
const scheduledJob = D.obj({
|
||||
id: D.str, kind: D.str.describe('broadcast or attendees'), eventId: D.optStr, broadcast: D.bool, channel: z.enum(['email', 'whatsapp']).or(D.str),
|
||||
recipient: D.optStr, scheduledAt: D.date, createdAt: D.date, status: D.str.describe('queued, sent, failed, cancelled'), attempts: D.num,
|
||||
sentAt: D.optDate, lastError: D.optStr, subject: D.str, html: D.str, text: D.str, message: D.str, hasHtml: D.bool, hasText: D.bool,
|
||||
});
|
||||
const scheduled = D.obj({ message: D.str, job: D.anyObj });
|
||||
const bannerShape = D.obj({ message: D.str, type: D.str.describe('info, warning, success, error'), liveFrom: D.optDate, liveTill: D.optDate });
|
||||
const savedFilter = D.obj({ id: D.str, page: D.str.describe('The list page it belongs to'), name: D.str, params: D.anyObj.describe('The filters'), isDefault: D.bool, updatedAt: D.date });
|
||||
const scanStats = D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.anyObj) }).partial();
|
||||
const monthly = D.obj({ thisMonth: D.num, lastMonth: D.num, pctChange: D.optNum });
|
||||
const reportBody = D.obj({
|
||||
title: D.str, subtitle: D.optStr, kind: z.enum(['table', 'layered']),
|
||||
table: D.obj({ columns: z.array(D.str), rows: z.array(z.array(z.unknown())) }).optional().describe('kind table'),
|
||||
layered: D.obj({ header: D.optStr, sections: z.array(D.anyObj) }).optional().describe('kind layered: titled sections, each with its rows'),
|
||||
orientation: z.enum(['portrait', 'landscape']).optional(), stats: z.array(D.anyObj).optional().describe('Figures shown above the table'),
|
||||
chart: D.anyObj.optional(), note: D.optStr, extraTables: z.array(D.anyObj).optional(),
|
||||
});
|
||||
/** 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, documented: { body: D.obj({ challengeId: D.str }), response: challenge }, 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) }), documented: { response: D.message }, 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, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str }), response: D.message }, v1: users.resetPassword });
|
||||
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str.describe('At least 8 characters'), acceptTerms: D.optBool }), response: signedIn }, v1: users.activateAccount });
|
||||
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, documented: { response: D.obj({ message: D.str, token: D.str.describe('A new token for this device (the old one stops working)') }) }, 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, documented: { body: D.obj({ password: D.str, deleteData: D.optBool.describe('Also ask for my personal information to be deleted') }), response: D.message }, v1: users.closeAccount });
|
||||
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', documented: { response: z.array(D.obj({ id: D.str, type: D.str.describe('login, password_changed, two_factor_enabled…'), device: D.optStr, createdAt: D.date })) }, 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, documented: { body: D.obj({ password: D.str }), response: D.ok }, 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, documented: { body: D.obj({ method: z.enum(['email', 'whatsapp']), password: D.str }), response: challenge }, 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, documented: { body: D.obj({ challengeId: D.str, code: D.str }), response: D.obj({ twoFactorEnabled: z.literal(true), twoFactorMethod: D.str }) }, 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, documented: { body: D.obj({ password: D.str }), response: D.obj({ twoFactorEnabled: z.literal(false), twoFactorMethod: z.null() }) }, 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'), documented: { response: D.obj({ exists: D.bool, hasEmail: D.optBool, hasPhone: D.optBool, user: D.obj({ id: D.str, name: D.str, email: D.optStr, phoneNumber: D.optStr, notificationPreference: D.optStr }).nullish() }) }, 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'), documented: { response: D.v1Page(D.user) }, v1: users.getUsers,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, documented: { response: D.user.extend({ scopedCampusIds: D.ids.optional().describe('Campuses they\'re limited to') }) }, 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, documented: { body: D.obj({ name: D.optStr, email: D.optStr, phoneNumber: D.optStr, role: D.optStr.describe('admin, supervisor, staff or user'), isActive: D.optBool, password: D.optStr.describe('Set a new password'), notificationPreference: D.optStr.describe('email, whatsapp or both'), twoFactorEnabled: D.optBool.describe('Only to turn it off'), scopedCampusIds: D.ids.optional().describe('Limit staff to these campuses (empty: all)') }), response: D.user }, v1: users.updateUser });
|
||||
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, documented: { response: D.message }, v1: users.deleteUser });
|
||||
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, documented: { response: D.message }, 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, documented: { response: D.message }, 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'), documented: { response: z.array(D.event.extend({ eventOptions: z.array(D.eventOption).optional(), isSoldOut: D.optBool })) }, v1: events.getEvents,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', documented: { response: z.array(D.event) }, 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, documented: { body: emailAttendees, response: reach }, 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, documented: { body: emailAttendees.extend({ scheduledAt: D.date }), response: scheduled }, 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, documented: { body: whatsappAttendees, response: reach }, 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, documented: { body: whatsappAttendees.extend({ scheduledAt: D.date }), response: scheduled }, 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';
|
||||
const who = D.obj({
|
||||
userIds: D.ids.optional().describe('Chosen accounts'),
|
||||
...(channel === 'email' ? { emails: D.optStr.describe('Typed-in addresses, separated by commas or new lines') } : { phones: D.optStr.describe('Typed-in numbers, separated by commas or new lines') }),
|
||||
eventId: D.optStr.describe('Everyone registered for this event'), kind: kind.optional().describe('Default news'),
|
||||
});
|
||||
const content = channel === 'email' ? { subject: D.str, html: D.optStr, text: D.optStr } : { message: D.str };
|
||||
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, documented: { body: who, response: reach.extend({ event: D.anyObj.nullish(), placeholders: z.array(D.str).optional() }) }, 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, documented: { body: who.extend(content), response: reach }, v1: send });
|
||||
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, documented: { body: who.extend(content).extend({ scheduledAt: D.date }), response: scheduled }, 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'), documented: { response: D.obj({ jobs: z.array(scheduledJob) }) }, 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, documented: { body: D.obj({ scheduledAt: D.optDate, subject: D.optStr, html: D.optStr, text: D.optStr, message: D.optStr.describe('WhatsApp') }), response: D.obj({ message: D.str, job: scheduledJob }) }, 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, documented: { response: D.message }, 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 }), documented: { body: D.obj({ eventId: D.str, jobs: z.array(D.obj({ scheduledAt: D.date, subject: D.str, html: D.optStr, text: D.optStr, audience: z.enum(['sponsors']).optional(), promoEventId: D.optStr })).min(1) }), response: D.obj({ message: D.str, jobs: z.array(D.anyObj) }) }, v1: auto.scheduleAutomations,
|
||||
});
|
||||
|
||||
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', documented: { response: D.obj({ tokenMasked: D.str, instanceId: D.str, hasToken: D.bool, hasInstance: D.bool, configured: D.bool, webhookUrl: D.optStr }) }, v1: wa.getConfigHandler });
|
||||
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, documented: { body: D.obj({ token: D.optStr, instanceId: D.optStr }), response: D.message }, v1: wa.saveConfigHandler });
|
||||
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr }), response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.createInstanceHandler });
|
||||
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', documented: { response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.deleteInstanceHandler });
|
||||
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s status answer') }, v1: wa.getStatusHandler });
|
||||
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s answer, with the QR code image') }, 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,
|
||||
documented: { body: action === 'request-code' ? D.obj({ phoneNumber: D.str }) : D.anyObj, response: D.anyObj.describe('The WhatsApp service\'s answer') },
|
||||
v1: handler,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', documented: { response: bannerShape }, 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, documented: { body: bannerShape.partial().describe('An empty message takes the banner down'), response: bannerShape }, 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'), documented: { response: z.array(savedFilter) }, 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, documented: { body: D.obj({ page: D.str, name: D.str, params: D.anyObj, isDefault: D.optBool }), response: savedFilter }, 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, documented: { body: D.obj({ name: D.optStr, params: D.anyObj.optional(), isDefault: D.optBool }), response: savedFilter }, 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, documented: { response: D.ok }, 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], documented: { response: D.obj({ events: z.array(D.obj({ id: D.str, title: D.str, startsAt: D.date, endsAt: D.date, sessionLabel: D.optStr, places: D.num, checkedIn: D.num })).describe('Today\'s events with places and check-ins'), money: D.obj({ today: D.rand, todayCount: D.num, week: D.rand }).nullish(), pending: z.array(D.obj({ key: D.str, label: D.str, count: D.num, href: D.str, tone: D.optStr })).describe('What\'s waiting for you') }) }, 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'), documented: { response: D.obj({ results: z.array(D.obj({ type: D.str.describe('event, payment, person, registration'), id: D.str, title: D.str, subtitle: D.optStr, href: D.optStr })) }) }, 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], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj) }) }, v1: stats.getStaffDashboardStats });
|
||||
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, v1: stats.getSupervisorDashboardStats });
|
||||
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, 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'), documented: { response: D.obj({ funnel: D.anyObj.nullish(), activeEvents: D.num, registrations: monthly, ticketsSold: monthly, revenue: monthly, donations: monthly, trend: z.array(D.obj({ date: D.str, revenue: D.rand })), topEvents: z.array(D.obj({ eventId: D.str, title: D.str, revenue: D.rand, registrations: D.num, ticketsSold: D.num })) }) }, 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, documented: { body: D.obj({ sessionId: D.str.describe('The browser\'s analytics id'), eventId: D.str, stage: z.enum(['view', 'start', 'submit', 'paid']), source: D.optStr.describe('Where they came from') }), response: z.null().describe('Always 204, no content') }, 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('eventIds', 'eventId', 'from', 'to'), documented: { response: D.obj({ perEvent: z.array(D.anyObj).describe('The same for each event') }).describe('Counts per stage (view, start, submit, paid), sources and devices, and per ticket') }, 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' }, documented: { body: reportBody }, 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' }, documented: { body: reportBody }, v1: reports.generateExcel });
|
||||
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ subject: D.optStr, body: D.optStr.describe('Text of the email') }), response: D.message }, v1: reports.emailPdf });
|
||||
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ caption: D.optStr }), response: D.message }, v1: reports.whatsappPdf });
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import * as D from '../docs';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { pageOf, single } from '../paging';
|
||||
@@ -207,6 +208,16 @@ export const bulk = defineRoute({
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
|
||||
response: single(z.looseObject({})),
|
||||
documented: {
|
||||
body: D.obj({
|
||||
action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500),
|
||||
method: D.optStr.describe('markPaid: cash, eft, card or other'), paidAt: D.optDate.describe('markPaid'),
|
||||
fromOptionId: D.optStr.describe('moveOption'), toOptionId: D.optStr.describe('moveOption'),
|
||||
setId: D.optStr.describe('allocate'), groupId: D.optStr.describe('allocate'),
|
||||
channel: z.enum(['email', 'whatsapp']).optional().describe('message'), subject: D.optStr.describe('message (email)'), text: D.optStr.describe('message'),
|
||||
}),
|
||||
response: D.obj({ action: D.str, done: D.num, skipped: D.num, failed: D.num, results: z.array(D.obj({ id: D.str, ok: D.bool, skipped: D.optBool, message: D.optStr })) }),
|
||||
},
|
||||
async handler({ body, req }) {
|
||||
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
|
||||
return { data: plain(await runBulk(req, body)) };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import * as D from '../docs';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { single } from '../paging';
|
||||
@@ -74,6 +75,7 @@ export const compareSeries = defineRoute({
|
||||
method: 'get', path: '/series/:id/compare', tag: 'Series', summary: 'Year on year',
|
||||
description: 'Registrations, tickets, income and attendance for each event in the series that you may see, oldest first.',
|
||||
access: 'supervisor', campusScope: 'scopeList (eventWhere in compareSeries)', params: idParams, response: single(z.looseObject({})),
|
||||
documented: { response: D.obj({ series: D.anyObj, events: z.array(D.obj({ eventId: D.str, title: D.str, startDate: D.date, endDate: D.date, campus: D.anyObj.nullish(), isActive: D.bool, sessions: D.optNum, registrations: D.num, tickets: D.num, income: D.rand, checkedIn: D.num, attendanceRate: D.optNum.describe('%'), change: D.obj({ registrations: D.optNum.describe('% change from the year before'), tickets: D.optNum.describe('% change from the year before'), income: D.optNum.describe('% change from the year before'), checkedIn: D.optNum.describe('% change from the year before') }).nullable() })) }) },
|
||||
async handler({ params, req }) { return { data: plain(await series.compareSeries(params.id, await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
@@ -137,6 +139,7 @@ export const buyPass = defineRoute({
|
||||
description: 'Registers you at every event on it, then answers with a checkout to pay (or issues the tickets when it\'s free).',
|
||||
access: 'user', status: 201, params: idParams,
|
||||
body: z.object({ quantity: z.number().int().min(1).max(50).default(1), provider: z.string().max(40).optional() }),
|
||||
documented: { response: D.obj({ purchaseId: D.str, total: D.rand, paid: D.bool.describe('Free: the tickets are issued straight away'), redirectUrl: D.optStr.describe('Where to send them to pay'), provider: D.optStr, message: D.optStr }) },
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ params, body, user }) { return { data: plain(await series.buyPass(params.id, user!.id, body)) }; },
|
||||
});
|
||||
@@ -144,6 +147,7 @@ export const buyPass = defineRoute({
|
||||
export const payPass = defineRoute({
|
||||
method: 'post', path: '/season-pass-purchases/:id/checkout', tag: 'Series', summary: 'Pay what\'s owed on a pass',
|
||||
access: 'user', params: idParams, body: z.object({ provider: z.string().max(40).optional() }), response: single(z.looseObject({})),
|
||||
documented: { response: D.obj({ redirectUrl: D.optStr, provider: D.optStr, message: D.optStr }) },
|
||||
async handler({ params, body, user }) { return { data: plain(await series.payPassPurchase(params.id, user!.id, body)) }; },
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
|
||||
* and `response` is `fileResponse`. `contentType` here is what the docs show.
|
||||
*/
|
||||
file?: { contentType: string };
|
||||
/**
|
||||
* Documentation only (v1Route, 1.52.1): the body and the answer's `data` as the v1 handler takes
|
||||
* and gives them, for the OpenAPI document. Not enforced — the handler gets what was sent and its
|
||||
* answer goes out as it is — but outside production anything that doesn't fit is logged
|
||||
* (`[api/v2] … doesn't match its documented …`), so the docs can't drift unnoticed.
|
||||
*/
|
||||
documented?: { body?: Schema; response?: Schema };
|
||||
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
|
||||
};
|
||||
|
||||
@@ -78,6 +85,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;
|
||||
}
|
||||
@@ -85,6 +96,15 @@ export function defineRoute<P extends Schema | undefined, Q extends Schema | und
|
||||
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
|
||||
(schema ? schema.parse(value) : undefined) as Infer<S>;
|
||||
|
||||
/** Outside production: says when a documented-only body or answer doesn't fit its documentation. */
|
||||
function warnUndocumented(def: AnyRoute, what: string, schema: Schema, value: unknown) {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
const checked = schema.safeParse(value);
|
||||
if (!checked.success) {
|
||||
console.warn(`[api/v2] ${def.method.toUpperCase()} ${def.path}: ${what} doesn't match its documented schema`, JSON.stringify(checked.error.issues.slice(0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds every defined route to the router. */
|
||||
export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
for (const def of list) {
|
||||
@@ -98,7 +118,9 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
req,
|
||||
res,
|
||||
};
|
||||
if (def.documented?.body) warnUndocumented(def, 'request body', def.documented.body, req.body ?? {});
|
||||
const result = await def.handler(ctx);
|
||||
if (def.documented?.response) warnUndocumented(def, 'answer', def.documented.response, (result as { data?: unknown })?.data);
|
||||
const checked = def.response.safeParse(result);
|
||||
if (!checked.success) {
|
||||
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 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';
|
||||
@@ -73,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;
|
||||
@@ -135,6 +152,8 @@ type V1RouteDef = {
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response?: z.ZodType;
|
||||
/** The body and the answer's `data`, for the docs only (see RouteDef.documented). */
|
||||
documented?: { body?: z.ZodType; response?: z.ZodType };
|
||||
status?: number;
|
||||
file?: { contentType: string };
|
||||
upload?: { field: string; description: string };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }) });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ 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(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1.52.1: the API document describes every route. Routes answered by a v1 handler carry
|
||||
// `documented` shapes — in the document, never enforced, and logged outside production when the
|
||||
// real body or answer doesn't fit them.
|
||||
|
||||
jest.mock('../src/middleware/authMiddleware', () => ({
|
||||
protect: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
optionalAuth: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
admin: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
staff: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
supervisor: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
import { defineRoute, mount } from '../src/v2/route';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
import { errorHandler } from '../src/v2/errors';
|
||||
|
||||
const documented = defineRoute({
|
||||
method: 'post', path: '/docs-test/things', tag: 'Test', summary: 'Make a thing', access: 'public',
|
||||
body: z.looseObject({}),
|
||||
response: z.object({ data: z.unknown() }),
|
||||
documented: { body: z.object({ name: z.string() }), response: z.object({ id: z.string(), name: z.string() }) },
|
||||
handler: ({ req }) => ({ data: (req.body as { name?: unknown }).name === 'odd' ? { id: 1 } : { id: 't1', name: String((req.body as { name?: unknown }).name) } }),
|
||||
});
|
||||
const action = defineRoute({
|
||||
method: 'post', path: '/docs-test/things/:id/poke', tag: 'Test', summary: 'Poke a thing', access: 'public',
|
||||
params: z.object({ id: z.string() }), query: z.looseObject({ from: z.string().optional() }), body: z.looseObject({}), response: z.object({ data: z.unknown() }),
|
||||
handler: () => ({ data: null }),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
mount(router, [documented, action]);
|
||||
router.use(errorHandler);
|
||||
app.use('/api/v2', router);
|
||||
|
||||
describe('documented shapes', () => {
|
||||
const doc = buildOpenApi({ version: 'test', list: [documented, action] }) as any;
|
||||
const op = doc.paths['/docs-test/things'].post;
|
||||
|
||||
test('are what the document shows, in place of the generic body and answer', () => {
|
||||
expect(op.requestBody.required).toBe(true);
|
||||
expect(op.requestBody.content['application/json'].schema.properties.name).toEqual({ type: 'string' });
|
||||
expect(op.responses['200'].content['application/json'].schema.properties.data.properties).toEqual({ id: { type: 'string' }, name: { type: 'string' } });
|
||||
});
|
||||
|
||||
test('an action with no input says it needs no body; parameters get their common description', () => {
|
||||
const poke = doc.paths['/docs-test/things/{id}/poke'].post;
|
||||
expect(poke.requestBody).toMatchObject({ required: true, description: expect.stringContaining('No body needed') });
|
||||
expect(poke.parameters).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', in: 'path', description: 'The id' }),
|
||||
expect.objectContaining({ name: 'from', in: 'query', description: expect.stringContaining('On or after') }),
|
||||
]));
|
||||
});
|
||||
|
||||
test('are never enforced: a body or answer that doesn\'t fit still goes through, and is logged', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const fits = await request(app).post('/api/v2/docs-test/things').send({ name: 'Bob' });
|
||||
expect(fits.status).toBe(200);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
const odd = await request(app).post('/api/v2/docs-test/things').send({ name: 'odd' });
|
||||
expect(odd.status).toBe(200);
|
||||
expect(odd.body).toEqual({ data: { id: 1 } });
|
||||
const noName = await request(app).post('/api/v2/docs-test/things').send({});
|
||||
expect(noName.status).toBe(200);
|
||||
expect(warn.mock.calls.map(c => c[0])).toEqual([
|
||||
expect.stringContaining('answer doesn\'t match its documented schema'),
|
||||
expect.stringContaining('request body doesn\'t match its documented schema'),
|
||||
]);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the whole API document', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../src/v2');
|
||||
const doc = buildOpenApi({ version: 'test' }) as any;
|
||||
// (Leaving out this file's own test routes.)
|
||||
const ops = Object.entries<any>(doc.paths).filter(([path]) => !path.startsWith('/docs-test')).flatMap(([path, methods]) => Object.entries<any>(methods).map(([method, op]) => ({ path, method, op })));
|
||||
const isBlank = (s: any) => !s || Object.keys(s).length === 0 || (s.type === 'object' && !s.properties && !s.additionalProperties && !s.description);
|
||||
|
||||
test('every JSON answer says what\'s in it', () => {
|
||||
const blank = ops.filter(({ op }) => {
|
||||
const json = Object.entries<any>(op.responses).find(([code]) => code.startsWith('2'))?.[1]?.content?.['application/json'];
|
||||
if (!json) return false;
|
||||
const data = json.schema?.properties?.data;
|
||||
return isBlank(data) || (data?.type === 'array' && isBlank(data.items));
|
||||
}).map(({ method, path }) => `${method.toUpperCase()} ${path}`);
|
||||
// A JSON file download (my data) is the one answer that's a file, not a shape.
|
||||
expect(blank).toEqual(['GET /me/privacy/export']);
|
||||
});
|
||||
|
||||
test('every body that takes something says what, and every parameter is described', () => {
|
||||
const undescribed = ops.flatMap(({ method, path, op }) => op.parameters.filter((p: any) => !p.description).map((p: any) => `${method} ${path} ${p.in}:${p.name}`));
|
||||
expect(undescribed).toEqual([]);
|
||||
const bodies = ops.filter(({ op }) => op.requestBody?.content?.['application/json']);
|
||||
for (const { op } of bodies) {
|
||||
const schema = op.requestBody.content['application/json'].schema;
|
||||
const takesSomething = (!!schema.properties && Object.keys(schema.properties).length > 0) || !!schema.additionalProperties?.type || !!schema.description;
|
||||
if (!takesSomething) expect(op.requestBody.description).toMatch(/No body needed/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -85,4 +85,16 @@ describe('v2 routes answered by v1 handlers', () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+66
-36
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.12** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-11-02, after **1.52.2** (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,9 +14,14 @@ 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.2 | `scripts/deploy.sh` stops first (listing them) when files in the install were changed or added by hand; `--check` runs only that; runs from a copy outside the install too; `ALLOW_LOCAL_CHANGES=1` overrides |
|
||||
| 1.52.1 | **API documentation complete:** every v2 route documents its body, answer and parameters — the ~300 answered by v1 handlers through `documented` shapes (`src/v2/docs.ts`), shown but not enforced, logged outside production when they don't fit; empty bodies say "No body needed". Test `tests/v2Docs.test.ts` |
|
||||
| 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 |
|
||||
@@ -24,6 +29,11 @@ encrypted settings unreadable or break existing installs.
|
||||
| 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` |
|
||||
@@ -39,9 +49,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` |
|
||||
@@ -55,10 +65,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 |
|
||||
@@ -66,25 +72,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.13 – 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
|
||||
|
||||
@@ -111,7 +129,7 @@ a test server (both done 2026-10-28).
|
||||
The backend declares `engines.node >= 22`. pg-boss 11 (1.51.7) runs in its own schema,
|
||||
`pgboss_v11` — it can't migrate pg-boss 10's `pgboss` schema; there's no `archive` table any more
|
||||
(finished runs stay in `job` until they expire).
|
||||
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
|
||||
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). It stops first if files in the install were changed or added by hand (`--check` to only check; `ALLOW_LOCAL_CHANGES=1` to go ahead anyway). Each install
|
||||
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
|
||||
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
|
||||
tested by running it against a throwaway clone with npm/pm2 stubbed: across the tracked →
|
||||
@@ -172,8 +190,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`.
|
||||
@@ -190,7 +208,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
|
||||
@@ -463,7 +482,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
|
||||
@@ -481,6 +501,16 @@ a test server (both done 2026-10-28).
|
||||
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
|
||||
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
|
||||
only allows declared ones.
|
||||
- **Document every v1Route (1.52.1):** `documented: { body?, response? }` with the shapes from
|
||||
`src/v2/docs.ts` (`D.obj`, `D.payment`, `D.message`…) — the body it reads and the `data` it answers.
|
||||
They go in the OpenAPI document but are **not enforced** (the handler still gets what was sent and
|
||||
its answer goes out as it is), so a mistake can't break a screen. Outside production a body or
|
||||
answer that doesn't fit is logged as `[api/v2] … doesn't match its documented schema` — grep the
|
||||
e2e output for it after changing a handler. `tests/v2Docs.test.ts` fails if a JSON answer is
|
||||
undocumented or a parameter has no description (common query names are described once in
|
||||
`QUERY_HELP`, path ones in `PATH_HELP`). An action that takes no input keeps `body: loose`; the
|
||||
document says "No body needed". Because the frontend's types come from the document, a documented
|
||||
shape that's wrong usually shows up as a frontend type error too.
|
||||
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
|
||||
doesn't change when it happens.
|
||||
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
|
||||
@@ -517,7 +547,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)
|
||||
|
||||
@@ -544,8 +574,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.
|
||||
|
||||
@@ -590,13 +621,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.
|
||||
@@ -607,8 +639,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
@@ -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**, 1.51.11 **money on v2** and 1.51.12 **tickets and
|
||||
attendance on v2** shipped. **Next: 1.51.13 – 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.12 ✅, 1.51.13 – 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/`),
|
||||
|
||||
+78
-2
@@ -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 }) => {
|
||||
@@ -263,6 +273,72 @@ test.describe("API v2", () => {
|
||||
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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.52.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { useState } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
|
||||
const res = await (apiV2Data("post", "/auth/password/forgot", { body: { email } }) as Promise<{ message?: string }>);
|
||||
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
|
||||
} catch (err) {
|
||||
try {
|
||||
|
||||
@@ -3,12 +3,12 @@ import React, { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { saveToken } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
function ActivateAccountContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -29,7 +29,7 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
|
||||
const data = await (apiV2Data("post", "/auth/activate", { body: { token, password, acceptTerms: accepted } }) as Promise<{ token?: string }>);
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
|
||||
@@ -42,7 +42,7 @@ export default async function CampusPage({ params }: { params: Promise<{ slug: s
|
||||
const campus = await loadCampus(slug);
|
||||
if (!campus) notFound();
|
||||
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || [])
|
||||
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
|
||||
|
||||
@@ -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’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);
|
||||
|
||||
@@ -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." });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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’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">
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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";
|
||||
@@ -35,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() ?? "" }])));
|
||||
@@ -65,7 +64,7 @@ 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) });
|
||||
|
||||
@@ -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,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}`;
|
||||
|
||||
@@ -1033,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't make it</button>}
|
||||
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ export default function ServingPage() {
|
||||
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
|
||||
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can't make it{c.note ? `: “${c.note}”` : ""}</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
<button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: c.swapId }, authToken: token, body: {} }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
@@ -170,10 +170,10 @@ export default function ServingPage() {
|
||||
{r.mine
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You're serving</span>
|
||||
: r.coverNeeded.length > 0
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
? <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: r.coverNeeded[0].swapId }, authToken: token, body: {} }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
: r.full
|
||||
? <span className="text-sm text-gray-500">Full</span>
|
||||
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
: <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-roles/{id}/sign-up", { params: { id: r.id }, authToken: token, body: {} }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
</div>
|
||||
))}
|
||||
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
|
||||
|
||||
@@ -32,7 +32,7 @@ type Event = {
|
||||
};
|
||||
|
||||
export default async function EventsPage() {
|
||||
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const events = await apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
if (e.cashupStatus === "closed") return false;
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useScrollSpy } from "@/hooks/useScrollSpy";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
|
||||
|
||||
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
|
||||
@@ -28,7 +29,7 @@ function PrivacyNotice() {
|
||||
useEffect(() => {
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
|
||||
(apiV2Data("get", "/privacy/notice", { query: { version: versionParam || undefined } }) as Promise<PublicNotice>)
|
||||
.then(setNotice)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
|
||||
}, [versionParam]);
|
||||
|
||||
@@ -38,9 +38,9 @@ const FEATURES = [
|
||||
async function getOrgName(): Promise<string> {
|
||||
if (!API_BASE) return appName;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return appName;
|
||||
const settings = await res.json();
|
||||
const settings = (await res.json())?.data;
|
||||
return settings?.org_name || appName;
|
||||
} catch {
|
||||
return appName;
|
||||
@@ -49,7 +49,7 @@ async function getOrgName(): Promise<string> {
|
||||
|
||||
export default async function HomePage() {
|
||||
const [events, displayName] = await Promise.all([
|
||||
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
getOrgName(),
|
||||
]);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -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,10 +2,10 @@
|
||||
import React, { Suspense, useMemo, useState } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -24,7 +24,7 @@ function ResetPasswordContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
|
||||
await apiV2Data("post", "/auth/password/reset", { body: { token, password } });
|
||||
setStatus("Your password has been reset. You can now log in.");
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
@@ -13,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>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
||||
const series = await loadSeries(slug);
|
||||
if (!series) notFound();
|
||||
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || [])
|
||||
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { CheckCircle2, HandHeart } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
|
||||
|
||||
@@ -23,7 +23,7 @@ function CheckIn() {
|
||||
useEffect(() => {
|
||||
if (!code || loading || !token || sent.current) return;
|
||||
sent.current = true;
|
||||
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
|
||||
(apiV2Data("post", "/volunteering/check-in", { authToken: token, body: { qr: code } }) as Promise<Result>)
|
||||
.then(setResult)
|
||||
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
|
||||
}, [code, loading, token]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user