Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a952dedb | ||
|
|
61ce138447 | ||
|
|
b28c8a8876 | ||
|
|
8a424ac77e | ||
|
|
31aa787b17 | ||
|
|
51992814d2 | ||
|
|
a0ff8dfc89 | ||
|
|
88d9276960 | ||
|
|
1ae4c47001 | ||
|
|
3693d5f2c8 | ||
|
|
62b10c3db5 | ||
|
|
2b3b0e94c9 | ||
|
|
83e3aee40c | ||
|
|
f77b4f4164 | ||
|
|
36cabfee0d | ||
|
|
206a814973 | ||
|
|
311fe8b6f1 | ||
|
|
7ce25bf40b | ||
|
|
376b93a438 | ||
|
|
0cd78ab1b7 | ||
|
|
d49aebb1b0 | ||
|
|
98e5d05548 | ||
|
|
14ece27690 | ||
|
|
5b50b0ce4f | ||
|
|
99f16375b9 | ||
|
|
a62cac6335 | ||
|
|
c4fff7c835 | ||
|
|
e310743b92 | ||
|
|
b6c13de5dc | ||
|
|
2a5b12db20 | ||
|
|
8747431bd5 | ||
|
|
e95d52ccdd | ||
|
|
fdc3db8d23 | ||
|
|
1a25896999 | ||
|
|
97c368c898 | ||
|
|
94e4de8794 | ||
|
|
3a5b042525 | ||
|
|
a7231aad0c | ||
|
|
254a5daebd | ||
|
|
28e31ffb99 | ||
|
|
6ee1438e23 | ||
|
|
a45417948d | ||
|
|
31ee2a9aee | ||
|
|
da67f80d6a | ||
|
|
4b7fb19f37 | ||
|
|
0fc4f83c34 | ||
|
|
59fde801f7 | ||
|
|
733b23955b | ||
|
|
d6082693c2 | ||
|
|
16357e6006 |
+325
@@ -7,6 +7,331 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **AI helpers — the plumbing** (2.0, roadmap §15). The site calls AI through the Cross Events Hub
|
||||
(protocol §3b, Hub 1.6.0), which holds the Claude API key and a monthly allowance in rand per
|
||||
licence; no key is ever stored on the site. `GET /api/v2/ai/status` says whether to show the AI
|
||||
buttons: hidden when the licence has no AI (worked out from the licence token, without asking the
|
||||
Hub), disabled with the reason once the month's allowance is used up or the Hub can't be reached.
|
||||
The answer is cached for a minute and refreshed by every AI call. `<AiButton>` and
|
||||
`useAiStatus()` on the frontend; only supervisors and admins see AI.
|
||||
- **Site settings → AI** (admins): this month's use against the allowance, use per person, per
|
||||
helper and per day, a switch per helper, and an optional most-uses-per-person-per-day. Admins are
|
||||
emailed once a month when 80% is used. Changes are audited (`ai_settings_changed`).
|
||||
- Each AI call is logged in `AiCall` (person, helper, outcome, cost — never the prompt or answer);
|
||||
anonymising an account unlinks its calls, and calls go after 13 months. Anthropic is added to the
|
||||
operator register, switched off until a site uses AI. Migration `20261030120000_ai_helpers`.
|
||||
- v2 errors have a new code, `UNAVAILABLE` (503), for when the Hub or the AI service can't be
|
||||
reached.
|
||||
- **The five AI helpers' API** (supervisors and admins): `POST /api/v2/ai/draft-event` (the event
|
||||
form from a description, with ticket options, early bird, schedule and a budget from the notes),
|
||||
`/ai/summarise-report` (reports without people in them only), `/ai/draft-message` (email or
|
||||
WhatsApp, with `{{name}}` and the event placeholders), `/ai/summarise-feedback` (an event's
|
||||
feedback, sent without who answered; recorded in the access log) and `/ai/improve-text`
|
||||
(improve, shorter, or only fix mistakes). Every helper is told today's date. Email addresses,
|
||||
phone numbers and ID numbers in what's sent are swapped for placeholders and put back in the
|
||||
answer, so they never leave the site. Two checks are worked out in code as well as by the AI: a
|
||||
weekday that doesn't match its date, and costs a full event can't cover.
|
||||
- **AI buttons in the app** (only where the licence has AI; supervisors and admins):
|
||||
- New event → **Describe your event**: a few sentences fill in the form (details, dates, tickets,
|
||||
early bird; course dates as sessions), with the checks and a budget from the notes to look at
|
||||
first. Nothing changes until "Fill in the form".
|
||||
- **Improve / Shorter / Fix mistakes** under the event description, the site banner, and the
|
||||
email and WhatsApp message boxes; the suggestion replaces the text only with "Use this".
|
||||
- **Draft with AI** in Email attendees and WhatsApp attendees: a short note becomes the email's
|
||||
subject and body, or the WhatsApp message, with `{{name}}` and the event's details.
|
||||
- **Summarise this report** on reports without people in them, and **Summarise feedback** per
|
||||
event on the Feedback summary report.
|
||||
- Every suggestion says "AI suggestion — check it before saving".
|
||||
- **Privacy notice:** the built-in text says how the AI helpers use information and where requests
|
||||
go (sections 4 and 9). Sites with their own published notice see the change in the notice
|
||||
editor.
|
||||
- `npm run ai:eval` (backend): the helpers' eval set — fixed examples run through the site's Hub
|
||||
connection, with automatic checks on the facts and every answer printed to read.
|
||||
|
||||
## [1.52.3] - 2026-11-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Licence read-only mode works again.** The read-only guard (`src/hub/readOnly.js`) only listed
|
||||
v1 paths, and every screen has used `/api/v2` since 1.51.15 — so a suspended, revoked or
|
||||
unlicensed site could still create and change events, take registrations and start payments.
|
||||
The v2 successors of every blocked v1 route are now blocked too (answered as `READ_ONLY` with
|
||||
reason `LICENCE_READ_ONLY`). A test checks each blocked v2 path is a real route in the OpenAPI
|
||||
document, so a renamed route can't slip out of the list.
|
||||
|
||||
## [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
|
||||
|
||||
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
|
||||
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
|
||||
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
|
||||
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
|
||||
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
|
||||
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
|
||||
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
|
||||
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
|
||||
read v2's error shape.
|
||||
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
|
||||
404 instead of waiting.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
|
||||
set-up use v2.
|
||||
|
||||
## [1.51.11] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
|
||||
assigned to registrations, stats, providers and their settings, open checkouts), payment links
|
||||
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
|
||||
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
|
||||
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
|
||||
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
|
||||
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
|
||||
feedback (set-up, send now, the summary, the form by link).
|
||||
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
|
||||
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
|
||||
v1's `code` as `reason`; piped files become downloads.
|
||||
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
|
||||
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
|
||||
and donate pages, the user dashboard) uses v2.
|
||||
|
||||
## [1.51.10] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
|
||||
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
|
||||
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
|
||||
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
|
||||
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
|
||||
attendees and their form links, attendee form drafts and responses
|
||||
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
|
||||
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
|
||||
`registrationController.js` (now about 60 lines of v1 wrappers).
|
||||
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
|
||||
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
|
||||
door, manual registration, self-service, payments, forms, the attendee composers, reports and
|
||||
the user dashboard all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
|
||||
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
|
||||
and links are now 1.51.16.
|
||||
|
||||
## [1.51.9] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
|
||||
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
|
||||
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
|
||||
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
|
||||
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
|
||||
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
|
||||
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
|
||||
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
|
||||
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
|
||||
bundle, coupon and campus controllers are now thin v1 wrappers.
|
||||
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
|
||||
middleware's codes.
|
||||
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
|
||||
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
|
||||
coupon field and bundle quotes on the register page all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
|
||||
coupon field shows that reason as before.
|
||||
|
||||
## [1.51.8] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,89 @@ 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.3
|
||||
|
||||
- **A licence check fix.** When a site's licence has run out or isn't connected, the site is
|
||||
meant to go read-only (tickets still scan and all data can still be viewed and exported). Since
|
||||
1.51.15 that wasn't being applied; it is again. Sites with an active licence see no change.
|
||||
|
||||
## 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,
|
||||
certificates and group allocations now run on the new version of the server's connection to the
|
||||
app. They work just as before.
|
||||
|
||||
## 1.51.11
|
||||
|
||||
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
|
||||
feedback now run on the new version of the server's connection to the app. They work just as
|
||||
before.
|
||||
|
||||
## 1.51.10
|
||||
|
||||
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
|
||||
bulk actions — now run on the new version of the server's connection to the app. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.9
|
||||
|
||||
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
|
||||
now run on the new version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.8
|
||||
|
||||
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.52.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.52.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.52.3",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -17,7 +17,8 @@
|
||||
"set-demo": "node scripts/set-demo.js",
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"openapi": "tsx scripts/openapi.ts"
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"ai:eval": "tsx scripts/ai-eval.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ai_settings_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"helper" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"chargedCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_createdAt_idx" ON "AiCall"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_userId_createdAt_idx" ON "AiCall"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- 2.0 AI helpers: Anthropic in the operator register, off until the site uses AI.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Anthropic (Claude), through the software provider''s Hub', 'AI helpers for staff: drafting events and messages, improving text, summarising reports and feedback', 'The text a staff member asks to draft or improve, report figures, and feedback comments with names and contact details removed. Not kept by the software provider; Anthropic''s commercial terms apply', 'United States', 'https://www.anthropic.com/legal/privacy', false, 9, CURRENT_TIMESTAMP);
|
||||
@@ -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;
|
||||
@@ -165,6 +165,7 @@ model User {
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
aiCalls AiCall[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -505,6 +506,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 +788,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
|
||||
@@ -820,6 +834,7 @@ enum AdminAuditAction {
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
registrations_bulk
|
||||
ai_settings_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2315,3 +2330,21 @@ model SavedFilter {
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
/// One AI helper call (2.0). The Hub meters the money; this copy is for Site settings → AI (use
|
||||
/// per person, per helper and per day) and per-person daily limits. No prompt or answer text is
|
||||
/// kept. `userId` is cleared when the account is anonymised; rows go after 13 months.
|
||||
model AiCall {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
helper String
|
||||
// ok, refused, incomplete, failed, cap_reached, unavailable
|
||||
outcome String
|
||||
// What the church was charged, in cents (fractions of a cent).
|
||||
chargedCents Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The AI helpers' eval set (roadmap §15): `npm run ai:eval` in backend/.
|
||||
*
|
||||
* Runs fixed examples of all five helpers through this site's real Hub connection (so the site must
|
||||
* be connected and its licence must have an AI allowance), checks the facts that matter
|
||||
* automatically, and prints every answer for a person to read. Run it after changing a helper's
|
||||
* instructions or the model on the Hub, and compare with the last run. It costs about R1 a run
|
||||
* with Claude Sonnet 5.5; the calls count against this site's allowance.
|
||||
*
|
||||
* The examples come from the October 2026 model trial; the inputs are made up and hold no
|
||||
* personal information.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { prisma } from '../src/v2/legacy';
|
||||
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText } from '../src/ai/helpers';
|
||||
|
||||
type Check = [label: string, ok: boolean];
|
||||
type Case = { name: string; run: (userId: string) => Promise<{ output: unknown; checks: Check[] }> };
|
||||
|
||||
const has = (text: unknown, re: RegExp) => re.test(JSON.stringify(text));
|
||||
const near = (n: number, target: number, give = 1) => Math.abs(n - target) <= give;
|
||||
|
||||
const CAMP_NOTES = 'Youth camp for grades 8–12, Fri 14 March 18:00 to Sun 16 March 14:00 at Camp Wartburg near Pietermaritzburg. 60 places. R450 per person, R400 if they book by 31 January. Parents must give consent. Theme "Rooted" (Colossians 2:7). Costs: venue R180 per person per night including meals, bus hire R6 500 return, speaker gift R1 000, T-shirts R95 each. Friday evening worship, Saturday sessions morning and evening with sport in the afternoon, Sunday morning service before we leave. Bring a sleeping bag, Bible and a torch.';
|
||||
|
||||
const FEEDBACK_COMMENTS = [
|
||||
'The keynote on Saturday morning was exactly what I needed this year.', 'Could not hear a thing in Hall B, the sound kept cutting out.',
|
||||
'Lunch queue took 40 minutes, we missed the start of the afternoon session.', 'Loved the worship, so good to be together again.',
|
||||
'Breakouts were too short, just as it got deep we had to leave.', 'Check-in with the QR code was so quick, well done.',
|
||||
'Parking was a nightmare, we parked on the road and walked.', 'Please have childcare next year, I almost could not come.',
|
||||
'Speaker in the parenting breakout was brilliant.', 'Sound in Hall B again, the mic was crackling the whole time.',
|
||||
'Food ran out of vegetarian options by the time we got there.', 'The whole day felt so well organised.',
|
||||
'More time for breakouts please, and maybe fewer of them.', 'Lunch took forever.', 'Loved the gift bags!',
|
||||
'Hall A was freezing, the aircon was too cold.', 'The app reminders the day before were really helpful.',
|
||||
'Would love a longer Q&A with the main speaker.', 'Queue for lunch was very long, maybe stagger the breakouts?',
|
||||
'Parking marshals would help a lot.', 'Worship team was amazing.', 'Childcare or a kids programme would let young moms come.',
|
||||
'Some of the slides were hard to read from the back.', 'Best conference yet, thank you to the whole team.',
|
||||
'I would pay a bit more for a better lunch.', 'Hall B sound please fix it.',
|
||||
];
|
||||
|
||||
const CASES: Case[] = [
|
||||
{
|
||||
name: 'Draft an event — full notes with costs',
|
||||
async run(userId) {
|
||||
const r = await draftEvent(userId, CAMP_NOTES);
|
||||
const d = r.draft;
|
||||
return {
|
||||
output: r,
|
||||
checks: [
|
||||
['price R450, early bird R400 until 31 January', d.ticketOptions[0]?.priceRand === 450 && d.ticketOptions[0]?.earlyBirdPriceRand === 400 && d.ticketOptions[0]?.earlyBirdUntil.endsWith('-01-31')],
|
||||
['60 places, parental consent', d.capacity === 60 && d.needsParentalConsent],
|
||||
['budget R34 800, break-even 78', r.totalCostRand === 34800 && r.breakEvenPlaces === 78],
|
||||
['warns the camp is short even when full', r.warnings.some(w => w.includes('short'))],
|
||||
['times not in the notes left empty (only 18:00 and 14:00 given)', d.sessions.every(s => !s.startTime || s.startTime === '18:00')],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise a report — a month of payments',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseReport(userId, {
|
||||
reportKey: 'revenue', title: 'Revenue summary — October 2026 (September for comparison)',
|
||||
columns: ['Method', 'October payments', 'October amount', 'September payments', 'September amount'],
|
||||
rows: [['Card', 96, 'R42 300', 81, 'R35 100'], ['EFT', 14, 'R18 000', 17, 'R21 500'], ['Cash at the door', 31, 'R6 250', 22, 'R4 100'], ['Refunds', 4, 'R2 700', 2, 'R450']],
|
||||
stats: [{ label: 'Card payments waiting for the provider over 2 days', value: '3 (R1 350)' }],
|
||||
});
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['October total R66 550', has(summary, /66[\s ]?550/)],
|
||||
['up on September (R60 700)', has(summary, /60[\s ]?700|5[\s ]?850|9[.,]6/)],
|
||||
['the 3 waiting card payments need checking', has(summary.watchOut, /1[\s ]?350|3 (online |card )?payments/i)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise feedback — women\'s conference',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseFeedback(userId, {
|
||||
event: { title: 'Women\'s Conference 2026' }, responses: 112, invited: 186,
|
||||
questions: [
|
||||
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 }, { label: 'Venue', type: 'rating', answered: 110, average: 3.9 },
|
||||
{ label: 'Food', type: 'rating', answered: 108, average: 3.6 }, { label: 'Comments', type: 'text', answered: FEEDBACK_COMMENTS.length, texts: FEEDBACK_COMMENTS },
|
||||
],
|
||||
});
|
||||
// A theme may be split (e.g. "lunch queues" and "vegetarian food"): add up every match.
|
||||
const count = (re: RegExp) => summary.improve.filter(t => re.test(t.theme)).reduce((n, t) => n + t.mentions, 0);
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['lunch/food raised by about 5', near(count(/lunch|food|queue/i), 5)],
|
||||
['Hall B sound raised by 3', near(count(/sound|hall b/i), 3, 0)],
|
||||
['quotes copied exactly', summary.quotes.every(q => FEEDBACK_COMMENTS.includes(q))],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — WhatsApp reminder',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'whatsapp', note: 'Youth camp this weekend: remind them to bring a sleeping bag; the bus leaves at 7:30 on Friday from the church car park.' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['uses {{name}}', message.body.includes('{{name}}')],
|
||||
['keeps 7:30, sleeping bag, car park', /7:30/.test(message.body) && /sleeping bag/i.test(message.body) && /car park/i.test(message.body)],
|
||||
['no "reminda" (seen with Haiku)', !/reminda/i.test(message.body)],
|
||||
['no event placeholders without an event', !/\{\{event\./.test(message.body)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — email about a venue change',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'email', note: 'Men\'s breakfast this Saturday is moving from the church hall to the Protea Hotel on Main Road because the hall is being renovated. Same time, 7:00. Park at the back of the hotel. Price stays R120, nothing to do if you\'ve paid. Speaker is still Pastor Mike. Questions: events@church.example.org' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['has a subject', message.subject.trim().length > 5],
|
||||
['keeps the facts', /Protea/.test(message.body) && /7:00/.test(message.body) && /R120/.test(message.body)],
|
||||
['the email address came back intact', message.body.includes('events@church.example.org')],
|
||||
['no event placeholders without an event', !/\{\{event\./.test(`${message.subject} ${message.body}`)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Improve this text — a messy description',
|
||||
async run(userId) {
|
||||
const { result } = await improveText(userId, { text: 'join us for are anual family fun day!!! theres jumping castles, food stalls and a talent show for the kids. Bring the hole family its going to be awesome. tickets R50 for adults kids under 12 free. starts 9 till 2 on the church field, if it rains we move inside to the hall. please register so we know how many boerewors rolls to make lol', mode: 'improve', where: 'description' });
|
||||
return {
|
||||
output: result,
|
||||
checks: [
|
||||
['spelling fixed', /annual/i.test(result.text) && /whole family/i.test(result.text) && !/\bare anual\b/.test(result.text)],
|
||||
['every fact kept (R50, under 12, 9 to 2, hall)', /R50/.test(result.text) && /12/.test(result.text) && /\b9\b/.test(result.text) && /\b2\b/.test(result.text) && /hall/i.test(result.text)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const admin = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
if (!admin) throw new Error('No active admin to log the calls against.');
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
for (const c of CASES) {
|
||||
console.log(`\n━━ ${c.name}`);
|
||||
try {
|
||||
const { output, checks } = await c.run(admin.id);
|
||||
for (const [label, ok] of checks) {
|
||||
total++;
|
||||
if (ok) passed++;
|
||||
console.log(` ${ok ? '✓' : '✗'} ${label}`);
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
|
||||
} catch (e) {
|
||||
total++;
|
||||
console.log(` ✗ failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} of ${total} checks passed. Read the answers too: the checks only cover the facts.`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(passed === total ? 0 : 1);
|
||||
})().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* The five AI helpers (roadmap §15): each one's instructions, the shape of its answer, and what it
|
||||
* sends. Run through `runHelper` (./index.ts), which goes via the Hub.
|
||||
*
|
||||
* What leaves the site (POPIA): only what a helper needs, and never names, contact details,
|
||||
* medical or children's information.
|
||||
* - Email addresses, phone numbers and ID numbers in any free text are swapped for placeholders
|
||||
* like [[CONTACT1]] before sending, and put back in the answer (`protect` / `restore`).
|
||||
* - Reports: only those without people in them (REPORTS_WITHOUT_PEOPLE).
|
||||
* - Feedback: ratings, choice counts and comments, never who answered.
|
||||
*
|
||||
* Lessons from the October 2026 trial, built in: every helper is told today's date, and the checks
|
||||
* that matter most (a weekday that doesn't match its date, an event that loses money when full)
|
||||
* are worked out here in code as well, not left to the model.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { runHelper, type AiStatus } from './index';
|
||||
|
||||
// ─── Dates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAST_MS = 2 * 3600 * 1000;
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
/** "Today is Friday 30 October 2026." in South African time. */
|
||||
export function todayLine(now = new Date()): string {
|
||||
const d = new Date(now.getTime() + SAST_MS);
|
||||
return `Today is ${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}.`;
|
||||
}
|
||||
|
||||
// ─── Keeping contact details on the site ──────────────────────────────────────
|
||||
|
||||
const CONTACT_RE = [
|
||||
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // email
|
||||
/(?:\+27|\b0)(?:[\s-]?\d){9}\b/g, // South African phone numbers
|
||||
/\b\d{13}\b/g, // South African ID numbers
|
||||
];
|
||||
|
||||
export type Protected = { text: string; found: Map<string, string> };
|
||||
|
||||
/** Swap contact details for [[CONTACTn]], so they never leave the site. */
|
||||
export function protect(text: string, found: Map<string, string> = new Map()): Protected {
|
||||
let out = text;
|
||||
for (const re of CONTACT_RE) {
|
||||
out = out.replace(re, match => {
|
||||
for (const [k, v] of found) if (v === match) return k;
|
||||
const key = `[[CONTACT${found.size + 1}]]`;
|
||||
found.set(key, match);
|
||||
return key;
|
||||
});
|
||||
}
|
||||
return { text: out, found };
|
||||
}
|
||||
|
||||
/** Put the contact details back into an answer (strings anywhere inside it). */
|
||||
export function restore<T>(value: T, found: Map<string, string>): T {
|
||||
if (!found.size) return value;
|
||||
const fix = (s: string) => s.replace(/\[\[CONTACT\d+\]\]/g, k => found.get(k) ?? k);
|
||||
const walk = (v: unknown): unknown => (typeof v === 'string' ? fix(v) : Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
|
||||
return walk(value) as T;
|
||||
}
|
||||
|
||||
const KEEP_PLACEHOLDERS = 'Text like [[CONTACT1]] stands for a contact detail the site keeps private: copy it exactly where it belongs and never change it.';
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const str = { type: 'string' };
|
||||
const num = { type: 'number' };
|
||||
const int = { type: 'integer' };
|
||||
const bool = { type: 'boolean' };
|
||||
const list = (items: object) => ({ type: 'array', items });
|
||||
const obj = (properties: Record<string, object>, descriptions: Record<string, string> = {}) => ({
|
||||
type: 'object', additionalProperties: false, required: Object.keys(properties),
|
||||
properties: Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, descriptions[k] ? { ...v, description: descriptions[k] } : v])),
|
||||
});
|
||||
|
||||
// ─── 1. Draft an event ────────────────────────────────────────────────────────
|
||||
|
||||
export type EventDraftAnswer = {
|
||||
title: string; summary: string; description: string;
|
||||
startDate: string; startTime: string; endDate: string; endTime: string;
|
||||
location: string; capacity: number; needsParentalConsent: boolean; ageGroup: string;
|
||||
ticketOptions: { name: string; priceRand: number; description: string; earlyBirdPriceRand: number; earlyBirdUntil: string }[];
|
||||
sessions: { date: string; startTime: string; endTime: string; label: string }[];
|
||||
costs: { item: string; basis: string; amountRand: number }[];
|
||||
checks: string[];
|
||||
};
|
||||
|
||||
const EVENT_SCHEMA = obj({
|
||||
title: str, summary: str, description: str,
|
||||
startDate: str, startTime: str, endDate: str, endTime: str,
|
||||
location: str, capacity: int, needsParentalConsent: bool, ageGroup: str,
|
||||
ticketOptions: list(obj({ name: str, priceRand: num, description: str, earlyBirdPriceRand: num, earlyBirdUntil: str }, { earlyBirdPriceRand: '0 when there is none', earlyBirdUntil: 'YYYY-MM-DD, or empty' })),
|
||||
sessions: list(obj({ date: str, startTime: str, endTime: str, label: str })),
|
||||
costs: list(obj({ item: str, basis: str, amountRand: num }, { basis: 'How it is worked out, e.g. "R180 × 60 people × 2 nights"', amountRand: 'Total for this line at full capacity' })),
|
||||
checks: list(str),
|
||||
}, {
|
||||
summary: 'One sentence for the event card',
|
||||
description: 'The public event page: 2–4 short paragraphs, plain text',
|
||||
startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startTime: 'HH:MM, 24-hour, or empty', endTime: 'HH:MM, 24-hour, or empty',
|
||||
capacity: 'Places in total; 0 when not given',
|
||||
sessions: 'Only for an event that meets on several separate dates people attend, like a weekly course: one row per date. Empty for a single event, even one over a weekend.',
|
||||
checks: 'Things to look at before publishing: missing details, contradictions, money that does not add up. Empty if none.',
|
||||
});
|
||||
|
||||
const EVENT_SYSTEM = (now: Date) => [
|
||||
'You help a church events team set up an event in Cross Events, an event registration system. From the organiser\'s notes, fill in the event form.',
|
||||
todayLine(now),
|
||||
'- Title: short and clear. Description: for the public event page, warm and clear, 2–4 short paragraphs, plain text (no markdown): who it is for, what happens, what is included, what to bring if known.',
|
||||
'- South African rand; 24-hour times. Dates as YYYY-MM-DD: the next time that date comes round after today, unless the notes give a year.',
|
||||
'- If a weekday in the notes does not match its date, keep the date and say so in "checks".',
|
||||
'- Ticket options: the prices the notes give, with any early-bird price and its last day. One option when only one price is given.',
|
||||
'- Leave anything the notes do not give empty (or 0) rather than guess, including times.',
|
||||
'- Costs: one line per cost the notes give, worked out at full capacity, showing how. Never invent costs.',
|
||||
'- Never invent names, phone numbers, email addresses or links.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
/** Weekdays written next to dates in the notes ("Fri 14 March") that don't match the year used. */
|
||||
export function weekdayClashes(notes: string, year: number): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\.?,?\s+(\d{1,2})(?:st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*/gi;
|
||||
for (const m of notes.matchAll(re)) {
|
||||
const day = Number(m[2]);
|
||||
const month = MONTHS.findIndex(x => x.toLowerCase().startsWith(m[3].toLowerCase()));
|
||||
const actual = new Date(Date.UTC(year, month, day)).getUTCDay();
|
||||
const said = WEEKDAYS.findIndex(w => w.toLowerCase().startsWith(m[1].toLowerCase()));
|
||||
if (actual !== said) out.push(`The notes say ${WEEKDAYS[said]} ${day} ${MONTHS[month]}, but ${day} ${MONTHS[month]} ${year} is a ${WEEKDAYS[actual]}. Check the dates.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** R34 800 — plain spaces between thousands, as the AI writes them (en-ZA uses a no-break space). */
|
||||
const rands = (n: number) => `R${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
|
||||
|
||||
/** Money and date checks worked out in code, whatever the model said. */
|
||||
export function eventWarnings(notes: string, a: EventDraftAnswer): string[] {
|
||||
const out: string[] = [];
|
||||
const year = Number(a.startDate.slice(0, 4));
|
||||
if (year > 2000) out.push(...weekdayClashes(notes, year));
|
||||
if (a.startDate && a.endDate && `${a.endDate}T${a.endTime || '23:59'}` < `${a.startDate}T${a.startTime || '00:00'}`) out.push('The end is before the start.');
|
||||
const costs = a.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const main = a.ticketOptions[0];
|
||||
if (costs > 0 && main?.priceRand > 0) {
|
||||
const breakEven = Math.ceil(costs / main.priceRand);
|
||||
if (a.capacity > 0 && breakEven > a.capacity) {
|
||||
const full = a.capacity * main.priceRand;
|
||||
const early = main.earlyBirdPriceRand > 0 ? a.capacity * main.earlyBirdPriceRand : null;
|
||||
out.push(`The costs in the notes come to ${rands(costs)}, which needs ${breakEven} places at ${rands(main.priceRand)} — more than the ${a.capacity} available. Even full, the event takes ${rands(full)}${early ? ` (${rands(early)} if everyone books early)` : ''}, ${rands(costs - full)} short.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function draftEvent(userId: string, notes: string, now = new Date()): Promise<{ draft: EventDraftAnswer; warnings: string[]; totalCostRand: number; breakEvenPlaces: number | null; status: AiStatus }> {
|
||||
const p = protect(notes);
|
||||
const { output, status } = await runHelper('draft_event', userId, { system: EVENT_SYSTEM(now), input: p.text, schema: EVENT_SCHEMA, maxTokens: 4000, effort: 'low' });
|
||||
const draft = restore(output as EventDraftAnswer, p.found);
|
||||
const totalCostRand = draft.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const price = draft.ticketOptions[0]?.priceRand || 0;
|
||||
return { draft, warnings: eventWarnings(notes, draft), totalCostRand, breakEvenPlaces: totalCostRand > 0 && price > 0 ? Math.ceil(totalCostRand / price) : null, status };
|
||||
}
|
||||
|
||||
// ─── 2. Summarise a report ────────────────────────────────────────────────────
|
||||
|
||||
/** Reports with no people in them: only these can be summarised (keys from ReportCatalog). */
|
||||
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
|
||||
const MAX_ROWS = 400;
|
||||
|
||||
export type ReportInput = {
|
||||
reportKey: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
stats?: { label: string; value: string | number }[];
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
};
|
||||
|
||||
export type ReportSummary = { headline: string; insights: { title: string; detail: string }[]; watchOut: string[] };
|
||||
|
||||
const REPORT_SCHEMA = obj({ headline: str, insights: list(obj({ title: str, detail: str })), watchOut: list(str) }, { watchOut: 'Things that need someone to act; empty if none' });
|
||||
|
||||
const REPORT_SYSTEM = (now: Date) => [
|
||||
'You summarise a report for the leaders of a church that uses Cross Events, an event registration system. They are busy volunteers and staff, not analysts.',
|
||||
todayLine(now),
|
||||
'- A one-sentence headline, then 3–5 insights: trends, changes against earlier periods, outliers, and anything that needs action.',
|
||||
'- Use the report\'s numbers; work out percentages and differences where they help, and get them right. Never invent figures or causes; when you suggest a cause, say it is only a possibility.',
|
||||
'- Mind the dates against today: an event that has already happened can\'t still be promoted or paid for before it starts.',
|
||||
'- Plain, friendly South African English. Amounts in rand like R18 900.',
|
||||
].join('\n');
|
||||
|
||||
/** The report as compact text: stats, then the table as pipe-separated rows. */
|
||||
export function reportText(r: ReportInput): string {
|
||||
const lines = [`Report: ${r.title}`];
|
||||
if (r.subtitle) lines.push(r.subtitle);
|
||||
for (const s of r.stats || []) lines.push(`${s.label}: ${s.value}`);
|
||||
lines.push(`Columns: ${r.columns.join(' | ')}`);
|
||||
for (const row of r.rows.slice(0, MAX_ROWS)) lines.push(row.map(c => String(c ?? '').replace(/\s+/g, ' ')).join(' | '));
|
||||
if (r.rows.length > MAX_ROWS) lines.push(`(${r.rows.length - MAX_ROWS} more rows not shown)`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function summariseReport(userId: string, report: ReportInput, now = new Date()): Promise<{ summary: ReportSummary; status: AiStatus }> {
|
||||
if (!(REPORTS_WITHOUT_PEOPLE as readonly string[]).includes(report.reportKey)) {
|
||||
throw Object.assign(new ApiError('FORBIDDEN', 'This report lists people, so it can\'t be sent to the AI. Summaries work on totals-only reports.'), { reason: 'AI_REPORT_HAS_PEOPLE' });
|
||||
}
|
||||
const p = protect(reportText(report));
|
||||
const { output, status } = await runHelper('summarise_report', userId, { system: REPORT_SYSTEM(now), input: p.text, schema: REPORT_SCHEMA, maxTokens: 2000, effort: 'low' });
|
||||
return { summary: restore(output as ReportSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 3. Draft a message ───────────────────────────────────────────────────────
|
||||
|
||||
export type MessageAnswer = { subject: string; body: string };
|
||||
|
||||
const MESSAGE_SCHEMA = obj({ subject: str, body: str }, { subject: 'Empty for WhatsApp' });
|
||||
|
||||
const MESSAGE_SYSTEM = (channel: 'email' | 'whatsapp', now: Date, withEvent: boolean) => [
|
||||
'You write messages from a church events team to people registered for an event, from the organiser\'s short note.',
|
||||
todayLine(now),
|
||||
channel === 'whatsapp'
|
||||
? '- WhatsApp: plain text, at most 3 short sentences, at most one emoji. "subject" is empty.'
|
||||
: '- Email: a clear subject, then the body in short plain-text paragraphs, the most important thing first. Start with "Hi {{name}}," and end with "The events team".',
|
||||
withEvent
|
||||
? '- Use {{name}} for the person\'s first name. {{event.title}}, {{event.start}} and {{event.link}} are filled in with the event\'s details; use them only where the message needs them, and don\'t add a separate block of event details.'
|
||||
: '- Use {{name}} for the person\'s first name. Use no other {{…}} placeholders: name the event as the note does.',
|
||||
'- Use only the facts in the note and the event details; never invent times, places, prices, links or contact details, or add advice the note doesn\'t give.',
|
||||
'- Spell carefully: this goes to everyone registered.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
export type EventFacts = { title: string; start: string; location: string | null };
|
||||
|
||||
export async function draftMessage(userId: string, input: { channel: 'email' | 'whatsapp'; note: string; event?: EventFacts | null }, now = new Date()): Promise<{ message: MessageAnswer; status: AiStatus }> {
|
||||
const p = protect(input.note);
|
||||
const facts = input.event ? `\n\nEvent: ${input.event.title}, ${input.event.start}${input.event.location ? `, at ${input.event.location}` : ''}` : '';
|
||||
const { output, status } = await runHelper('draft_message', userId, {
|
||||
system: MESSAGE_SYSTEM(input.channel, now, !!input.event), input: `Note: ${p.text}${facts}`, schema: MESSAGE_SCHEMA, maxTokens: input.channel === 'whatsapp' ? 600 : 1500, effort: 'low',
|
||||
});
|
||||
const message = restore(output as MessageAnswer, p.found);
|
||||
return { message: input.channel === 'whatsapp' ? { subject: '', body: message.body } : message, status };
|
||||
}
|
||||
|
||||
// ─── 4. Summarise feedback ────────────────────────────────────────────────────
|
||||
|
||||
export type FeedbackQuestion = { label: string; type: string; answered: number; average?: number | null; counts?: { label: string; value: number }[]; texts?: string[] };
|
||||
export type FeedbackSummary = { overall: string; liked: { theme: string; mentions: number }[]; improve: { theme: string; mentions: number }[]; quotes: string[]; nextTime: string[] };
|
||||
|
||||
const FEEDBACK_SCHEMA = obj({
|
||||
overall: str, liked: list(obj({ theme: str, mentions: int })), improve: list(obj({ theme: str, mentions: int })), quotes: list(str), nextTime: list(str),
|
||||
}, { liked: 'Most-mentioned first', improve: 'Most-mentioned first', quotes: 'Up to 3 short comments, copied exactly' });
|
||||
|
||||
const FEEDBACK_SYSTEM = [
|
||||
'You summarise the feedback people gave after a church event, for the team that ran it.',
|
||||
'- "overall": two sentences on how it went, using the ratings.',
|
||||
'- "liked" and "improve": the main themes, most-mentioned first, each with how many comments raised it. Count carefully: a comment counts once per theme.',
|
||||
'- "quotes": up to 3 short comments that capture the feedback well, copied exactly.',
|
||||
'- "nextTime": 2–4 concrete suggestions for next time, based only on the feedback.',
|
||||
'- The comments are anonymous; never guess who wrote one.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
const MAX_COMMENTS = 400;
|
||||
const MAX_COMMENT = 600;
|
||||
|
||||
/** The feedback as text: each question with its average or counts, and the comments numbered. */
|
||||
export function feedbackText(event: { title: string }, responses: number, invited: number, questions: FeedbackQuestion[]): string {
|
||||
const lines = [`Event: ${event.title}`, `Responses: ${responses}${invited ? ` of ${invited} asked` : ''}`];
|
||||
let n = 0;
|
||||
for (const q of questions) {
|
||||
if (q.average != null) lines.push(`${q.label} — average ${q.average} (${q.answered} answers)`);
|
||||
else if (q.counts) lines.push(`${q.label} — ${q.counts.map(c => `${c.label}: ${c.value}`).join(', ')}`);
|
||||
else if (q.texts?.length) {
|
||||
lines.push(`${q.label} — comments:`);
|
||||
for (const t of q.texts) {
|
||||
if (n++ >= MAX_COMMENTS) break;
|
||||
lines.push(`${n}. ${t.replace(/\s+/g, ' ').slice(0, MAX_COMMENT)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function summariseFeedback(userId: string, input: { event: { title: string }; responses: number; invited: number; questions: FeedbackQuestion[] }): Promise<{ summary: FeedbackSummary; status: AiStatus }> {
|
||||
const p = protect(feedbackText(input.event, input.responses, input.invited, input.questions));
|
||||
const { output, status } = await runHelper('summarise_feedback', userId, { system: FEEDBACK_SYSTEM, input: p.text, schema: FEEDBACK_SCHEMA, maxTokens: 2000, effort: 'low' });
|
||||
return { summary: restore(output as FeedbackSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 5. Improve this text ─────────────────────────────────────────────────────
|
||||
|
||||
export const IMPROVE_MODES = { improve: 'Make it clear and friendly', shorter: 'Make it shorter', fix: 'Only fix spelling and grammar' } as const;
|
||||
export type ImproveMode = keyof typeof IMPROVE_MODES;
|
||||
export type ImproveAnswer = { text: string; changes: string[] };
|
||||
|
||||
const IMPROVE_SCHEMA = obj({ text: str, changes: list(str) }, { changes: 'A short list of what changed and why' });
|
||||
|
||||
const IMPROVE_SYSTEM = (mode: ImproveMode, where: string) => [
|
||||
`You improve text a church events team wrote for ${where}.`,
|
||||
mode === 'fix'
|
||||
? '- Only fix spelling, grammar and punctuation. Change nothing else.'
|
||||
: mode === 'shorter'
|
||||
? '- Make it noticeably shorter and easier to scan, fixing spelling and grammar too.'
|
||||
: '- Fix spelling, grammar and punctuation, and make it clear and friendly, about the same length or shorter.',
|
||||
'- Keep every fact (times, dates, prices, places, ages) exactly; add none.',
|
||||
'- Plain text, no markdown. Keep the writer\'s warmth, and any humour that works in public. Keep placeholders like {{name}} exactly.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
const WHERE = { description: 'an event page', banner: 'a short banner across the top of the website', message: 'a message to people registered for an event' } as const;
|
||||
export type ImproveWhere = keyof typeof WHERE;
|
||||
|
||||
export async function improveText(userId: string, input: { text: string; mode: ImproveMode; where: ImproveWhere }): Promise<{ result: ImproveAnswer; status: AiStatus }> {
|
||||
const p = protect(input.text);
|
||||
const { output, status } = await runHelper('improve_text', userId, { system: IMPROVE_SYSTEM(input.mode, WHERE[input.where]), input: p.text, schema: IMPROVE_SCHEMA, maxTokens: Math.min(8000, 600 + Math.ceil(input.text.length / 2)), effort: 'low' });
|
||||
return { result: restore(output as ImproveAnswer, p.found), status };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* AI helpers (2.0, roadmap §15). The site never holds a Claude API key: every call goes through
|
||||
* the Cross Events Hub (docs/hub-protocol.md §3b), which checks the licence's monthly cap in rand,
|
||||
* calls Claude and meters the call. This module:
|
||||
* - works out what the AI buttons do (`aiStatus`): hidden, working, or disabled until the
|
||||
* allowance renews — from the licence token first, then the Hub (cached for a minute);
|
||||
* - runs one helper call (`runHelper`), with this site's own switches per helper and an optional
|
||||
* daily limit per person, and logs it in `AiCall` (no prompt or answer text);
|
||||
* - emails the site admins once a month when 80% of the allowance is used.
|
||||
* The helpers themselves (prompts, schemas, what they send) are in ./helpers.ts.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { prisma } from '../v2/legacy';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS Hub client and helpers aren't typed */
|
||||
const hub = require('../hub/client') as {
|
||||
PROTOCOL_VERSION: number;
|
||||
signedPost(path: string, body: object, opts?: { timeoutMs?: number }): Promise<any>;
|
||||
HubError: new (...args: any[]) => Error & { code: string; statusCode: number; data: any };
|
||||
};
|
||||
const { licenceStatus } = require('../hub/licence') as {
|
||||
licenceStatus(): Promise<{ connected: boolean; claims: { limits?: { aiMonthlyCapCents?: number } } | null; state: string; readOnly: boolean }>;
|
||||
};
|
||||
const email = require('../utils/email') as {
|
||||
sendMail(m: { to: string; subject: string; html: string; text: string }): Promise<unknown>;
|
||||
emailWrapper(body: string): string;
|
||||
isSmtpConfigured(): boolean;
|
||||
};
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<unknown> };
|
||||
|
||||
export const HELPERS = ['draft_event', 'summarise_report', 'draft_message', 'summarise_feedback', 'improve_text'] as const;
|
||||
export type HelperName = (typeof HELPERS)[number];
|
||||
|
||||
/** What the Hub says (§3b). Cents are charged rand, with fractions. */
|
||||
export type HubAiStatus = { enabled: boolean; capReached: boolean; capCents: number; usedCents: number; usedPercent: number; renewsOn: string | null };
|
||||
/** What the buttons need: the Hub's answer, whether it could be reached, and this site's switches. */
|
||||
export type AiStatus = HubAiStatus & { available: boolean; helpers: Record<HelperName, boolean> };
|
||||
|
||||
export type AiSettings = { helpers: Record<HelperName, boolean>; dailyLimitPerPerson: number };
|
||||
const SETTINGS_KEY = 'ai_settings';
|
||||
const WARNED_KEY = 'ai_warned_month';
|
||||
const STATUS_TTL_MS = 60 * 1000;
|
||||
const CALL_TIMEOUT_MS = 90 * 1000;
|
||||
const KEEP_CALLS_DAYS = 400;
|
||||
|
||||
const OFF: HubAiStatus = { enabled: false, capReached: false, capCents: 0, usedCents: 0, usedPercent: 0, renewsOn: null };
|
||||
|
||||
// ─── This site's settings ─────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SETTINGS: AiSettings = { helpers: Object.fromEntries(HELPERS.map(h => [h, true])) as Record<HelperName, boolean>, dailyLimitPerPerson: 0 };
|
||||
|
||||
export async function getAiSettings(): Promise<AiSettings> {
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||
let saved: Partial<AiSettings> = {};
|
||||
try { saved = row ? JSON.parse(row.value) : {}; } catch { /* keep the defaults */ }
|
||||
return {
|
||||
helpers: { ...DEFAULT_SETTINGS.helpers, ...Object.fromEntries(HELPERS.filter(h => typeof saved.helpers?.[h] === 'boolean').map(h => [h, saved.helpers![h]])) },
|
||||
dailyLimitPerPerson: Number.isInteger(saved.dailyLimitPerPerson) && saved.dailyLimitPerPerson! >= 0 ? saved.dailyLimitPerPerson! : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAiSettings(next: { helpers?: Partial<Record<HelperName, boolean | undefined>>; dailyLimitPerPerson?: number }, actor: { id: string; role: string }, ip?: string): Promise<AiSettings> {
|
||||
const current = await getAiSettings();
|
||||
const changed = Object.fromEntries(HELPERS.filter(h => typeof next.helpers?.[h] === 'boolean').map(h => [h, next.helpers![h]]));
|
||||
const value: AiSettings = {
|
||||
helpers: { ...current.helpers, ...changed },
|
||||
dailyLimitPerPerson: next.dailyLimitPerPerson ?? current.dailyLimitPerPerson,
|
||||
};
|
||||
await prisma.appSetting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(value) }, create: { key: SETTINGS_KEY, value: JSON.stringify(value) } });
|
||||
await logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'ai_settings_changed', targetType: 'setting', targetId: SETTINGS_KEY, metadata: value, ip });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let cached: { at: number; value: HubAiStatus; available: boolean } | null = null;
|
||||
|
||||
/** Keep what the Hub just said (every AI reply and error carries it). */
|
||||
function remember(value: unknown, available = true): void {
|
||||
const v = value as Partial<HubAiStatus> | null;
|
||||
if (v && typeof v.enabled === 'boolean') cached = { at: Date.now(), value: { ...OFF, ...v } as HubAiStatus, available };
|
||||
}
|
||||
|
||||
export function clearAiStatusCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap from the licence token (refreshed by every heartbeat): no Hub call is needed to know AI
|
||||
* is off, which is most sites.
|
||||
*/
|
||||
async function licenceCap(): Promise<number> {
|
||||
const l = await licenceStatus();
|
||||
if (!l.connected || !l.claims || l.readOnly) return 0;
|
||||
return Number(l.claims.limits?.aiMonthlyCapCents) || 0;
|
||||
}
|
||||
|
||||
async function hubStatus(fresh = false): Promise<{ value: HubAiStatus; available: boolean }> {
|
||||
if (!fresh && cached && Date.now() - cached.at < STATUS_TTL_MS) return cached;
|
||||
try {
|
||||
remember(await hub.signedPost('/api/installs/ai/status', { protocolVersion: hub.PROTOCOL_VERSION }));
|
||||
} catch (e) {
|
||||
const err = e as { code?: string; statusCode?: number };
|
||||
// A Hub before 1.6.0 has no AI; a released install has no licence: both mean off.
|
||||
if (err.statusCode === 404) cached = { at: Date.now(), value: OFF, available: true };
|
||||
else {
|
||||
console.warn('[ai] could not ask the Hub for the AI status:', (e as Error).message);
|
||||
// Unreachable: keep showing the buttons (they say AI is unavailable when pressed).
|
||||
cached = { at: Date.now(), value: { ...(cached?.value || OFF), enabled: true }, available: false };
|
||||
}
|
||||
}
|
||||
return cached!;
|
||||
}
|
||||
|
||||
export async function aiStatus({ fresh = false } = {}): Promise<AiStatus> {
|
||||
const settings = await getAiSettings();
|
||||
if ((await licenceCap()) <= 0) return { ...OFF, available: true, helpers: settings.helpers };
|
||||
const { value, available } = await hubStatus(fresh);
|
||||
return { ...value, available, helpers: settings.helpers };
|
||||
}
|
||||
|
||||
// ─── Running a helper ─────────────────────────────────────────────────────────
|
||||
|
||||
export type HelperRequest = {
|
||||
/** The helper's fixed instructions (the Hub has Claude cache them). */
|
||||
system: string;
|
||||
/** This request's text. Never names, contact details, medical or children's information. */
|
||||
input: string;
|
||||
/** A JSON schema: the answer comes back as JSON in this shape. */
|
||||
schema?: object;
|
||||
maxTokens: number;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
const failure = (code: 'FORBIDDEN' | 'RATE_LIMITED' | 'UNAVAILABLE', reason: string, message: string) => Object.assign(new ApiError(code, message), { reason });
|
||||
|
||||
function renewsText(renewsOn: string | null): string {
|
||||
if (!renewsOn) return 'next month';
|
||||
return new Date(`${renewsOn}T12:00:00Z`).toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', timeZone: 'Africa/Johannesburg' });
|
||||
}
|
||||
|
||||
/** The Hub's errors (§3b) as v2 errors, and what to log. */
|
||||
function fromHubError(e: { code?: string; statusCode?: number; message: string; data?: any }): { error: ApiError; outcome: string } {
|
||||
remember(e.data?.status);
|
||||
switch (e.code) {
|
||||
case 'AI_CAP_REACHED':
|
||||
return { outcome: 'cap_reached', error: failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(e.data?.status?.renewsOn ?? null)}.`) };
|
||||
case 'AI_OFF': case 'LICENCE_INACTIVE': case 'AI_NOT_CONFIGURED':
|
||||
return { outcome: 'unavailable', error: failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.') };
|
||||
case 'AI_REFUSED':
|
||||
return { outcome: 'refused', error: failure('UNAVAILABLE', 'AI_REFUSED', "The AI couldn't help with this one. Try wording it differently.") };
|
||||
case 'AI_INCOMPLETE':
|
||||
return { outcome: 'incomplete', error: failure('UNAVAILABLE', 'AI_INCOMPLETE', 'The answer was too long and got cut off. Try asking for less.') };
|
||||
case 'AI_INPUT_TOO_LARGE':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_INPUT_TOO_LARGE', 'There is too much here to send in one go.') };
|
||||
case 'AI_FAILED':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_FAILED', "The AI's answer couldn't be used. Try again.") };
|
||||
default:
|
||||
return { outcome: 'unavailable', error: failure('UNAVAILABLE', 'AI_UNAVAILABLE', 'AI is unavailable right now. Try again in a few minutes.') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts of today in South African time, for the per-person daily limit. */
|
||||
function startOfToday(now = new Date()): Date {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
return new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), sast.getUTCDate()) - 2 * 3600 * 1000);
|
||||
}
|
||||
|
||||
let prunedOn = '';
|
||||
/** Calls older than about 13 months go (once a day, on the first call). */
|
||||
async function pruneOldCalls(now = new Date()): Promise<void> {
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
if (prunedOn === today) return;
|
||||
prunedOn = today;
|
||||
await prisma.aiCall.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_CALLS_DAYS * 86400000) } } }).catch(() => {});
|
||||
}
|
||||
|
||||
/** At 80% of the allowance, the site's admins get one email a month. */
|
||||
async function warnAt80(status: HubAiStatus): Promise<void> {
|
||||
if (!status.enabled || status.usedPercent < 80 || !status.renewsOn) return;
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: WARNED_KEY } });
|
||||
if (row?.value === status.renewsOn) return;
|
||||
await prisma.appSetting.upsert({ where: { key: WARNED_KEY }, update: { value: status.renewsOn }, create: { key: WARNED_KEY, value: status.renewsOn } });
|
||||
if (!email.isSmtpConfigured()) return;
|
||||
const admins = await prisma.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
|
||||
const when = renewsText(status.renewsOn);
|
||||
const text = `This site has used ${status.usedPercent}% of this month's AI allowance (R${(status.usedCents / 100).toFixed(2)} of R${(status.capCents / 100).toFixed(2)}). The AI buttons stop working when it runs out, until it renews on ${when}. Site settings → AI shows who used it and for what.`;
|
||||
for (const a of admins) {
|
||||
await email.sendMail({ to: a.email, subject: `AI allowance ${status.usedPercent}% used`, text, html: email.emailWrapper(`<p>${text}</p>`) })
|
||||
.catch(e => console.warn('[ai] 80% warning email failed:', (e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one helper call for a person. Resolves to the answer (text, or JSON when a schema was
|
||||
* given) and the new status; throws an ApiError with a `reason` (AI_OFF, AI_HELPER_OFF,
|
||||
* AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE…).
|
||||
*/
|
||||
export async function runHelper(helper: HelperName, userId: string, req: HelperRequest): Promise<{ output: unknown; status: AiStatus }> {
|
||||
const status = await aiStatus();
|
||||
if (!status.enabled) throw failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.');
|
||||
if (!status.helpers[helper]) throw failure('FORBIDDEN', 'AI_HELPER_OFF', 'This AI helper is switched off in Site settings → AI.');
|
||||
if (status.capReached) throw failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(status.renewsOn)}.`);
|
||||
const { dailyLimitPerPerson } = await getAiSettings();
|
||||
if (dailyLimitPerPerson > 0) {
|
||||
const today = await prisma.aiCall.count({ where: { userId, createdAt: { gte: startOfToday() }, outcome: { in: ['ok', 'refused', 'incomplete'] } } });
|
||||
if (today >= dailyLimitPerPerson) throw failure('RATE_LIMITED', 'AI_DAILY_LIMIT', `You've used the AI helpers ${dailyLimitPerPerson} times today, the most this site allows. Try again tomorrow.`);
|
||||
}
|
||||
|
||||
let outcome = 'ok';
|
||||
let chargedCents = 0;
|
||||
try {
|
||||
const reply = await hub.signedPost('/api/installs/ai/complete', { protocolVersion: hub.PROTOCOL_VERSION, helper, ...req }, { timeoutMs: CALL_TIMEOUT_MS });
|
||||
chargedCents = Number(reply.chargedCents) || 0;
|
||||
remember(reply.status);
|
||||
await warnAt80(reply.status).catch(e => console.warn('[ai] 80% check failed:', (e as Error).message));
|
||||
return { output: reply.output, status: { ...(cached?.value || OFF), available: true, helpers: status.helpers } };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e;
|
||||
const mapped = fromHubError(e as Error & { code?: string; data?: any });
|
||||
outcome = mapped.outcome;
|
||||
if ((e as { code?: string }).code === 'HUB_UNREACHABLE') remember(cached?.value || { ...OFF, enabled: true }, false);
|
||||
throw mapped.error;
|
||||
} finally {
|
||||
await prisma.aiCall.create({ data: { userId, helper, outcome, chargedCents } }).catch(err => console.warn('[ai] could not log the call:', (err as Error).message));
|
||||
await pruneOldCalls();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Usage (Site settings → AI) ───────────────────────────────────────────────
|
||||
|
||||
/** This month's use on this site: per person, per helper and per day (South African time). */
|
||||
export async function usageThisMonth(now = new Date()) {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
const from = new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), 1) - 2 * 3600 * 1000);
|
||||
const calls = await prisma.aiCall.findMany({ where: { createdAt: { gte: from } }, select: { userId: true, helper: true, outcome: true, chargedCents: true, createdAt: true } });
|
||||
const add = (m: Map<string, { uses: number; chargedCents: number }>, key: string, cents: number) => {
|
||||
const v = m.get(key) || { uses: 0, chargedCents: 0 };
|
||||
v.uses++;
|
||||
v.chargedCents += cents;
|
||||
m.set(key, v);
|
||||
};
|
||||
const byPerson = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byHelper = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byDay = new Map<string, { uses: number; chargedCents: number }>();
|
||||
for (const c of calls) {
|
||||
if (c.outcome === 'cap_reached' || c.outcome === 'unavailable') continue;
|
||||
add(byPerson, c.userId || '', c.chargedCents);
|
||||
add(byHelper, c.helper, c.chargedCents);
|
||||
add(byDay, new Date(c.createdAt.getTime() + 2 * 3600 * 1000).toISOString().slice(0, 10), c.chargedCents);
|
||||
}
|
||||
const people = await prisma.user.findMany({ where: { id: { in: [...byPerson.keys()].filter(Boolean) } }, select: { id: true, name: true } });
|
||||
const names = new Map(people.map(p => [p.id, p.name]));
|
||||
return {
|
||||
people: [...byPerson.entries()].map(([id, v]) => ({ userId: id || null, name: id ? names.get(id) || 'Unknown' : 'Removed account', ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
helpers: [...byHelper.entries()].map(([helper, v]) => ({ helper, ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
days: [...byDay.entries()].map(([day, v]) => ({ day, ...v })).sort((a, b) => a.day.localeCompare(b.day)),
|
||||
};
|
||||
}
|
||||
|
||||
// For tests.
|
||||
export const _internal = { fromHubError, startOfToday, renewsText };
|
||||
+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,325 +1,23 @@
|
||||
const prisma = require('../config/db');
|
||||
// Bundle deals (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
|
||||
const deals = require('../services/deals');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
|
||||
const { previewBundles } = require('../utils/coupons');
|
||||
const { basketBuyer, priceBasket } = require('./couponController');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
// @route GET /api/bundles/event/:eventId — with how often each has been used
|
||||
const listBundles = async (req, res) => { try { res.json(await deals.listBundles(req.params.eventId)); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
const createBundle = async (req, res) => { try { res.status(201).json(await deals.createBundle(req.params.eventId, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/bundles/:id
|
||||
const updateBundle = async (req, res) => { try { res.json(await deals.updateBundle(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/bundles/:id (a used one is switched off instead)
|
||||
const deleteBundle = async (req, res) => { try { res.json(await deals.deleteBundle(req.params.id, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/bundles/quote — the deals a basket gets, before any coupon
|
||||
const quoteBundles = async (req, res) => { try { res.json(await deals.quoteBundles(req.body || {}, actorOf(req))); } catch (e) { fail(res, e); } };
|
||||
|
||||
/**
|
||||
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
|
||||
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
|
||||
* null when the headline already says it.
|
||||
*/
|
||||
function valueOf(bundle) {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') {
|
||||
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
}
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
|
||||
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
}
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
// Items with prices loaded, so the standard value can be shown next to the bundle price.
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function shape(bundle, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return {
|
||||
...bundle,
|
||||
items,
|
||||
summary: describeBundleItems(bundle),
|
||||
headline: describeBundle(bundle),
|
||||
...valueOf(bundle),
|
||||
available: isBundleAvailable(bundle, now),
|
||||
};
|
||||
}
|
||||
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the item list. On update, fields
|
||||
* left out of the body are left alone; when any pricing field is sent, the bundle as a
|
||||
* whole (saved values plus the new ones) is checked. Items must belong to the event.
|
||||
*/
|
||||
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
|
||||
const data = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw new Error('Give the bundle a name');
|
||||
if (name.length > 80) throw new Error('Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
|
||||
|
||||
let items;
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
|
||||
|
||||
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
items = [...merged.values()];
|
||||
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({
|
||||
where: { id: { in: items.map(i => i.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function eventOr404(eventId, res) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
return event;
|
||||
}
|
||||
|
||||
// @desc An event's bundle deals with how often each has been used
|
||||
// @route GET /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const listBundles = async (req, res) => {
|
||||
try {
|
||||
await eventOr404(req.params.eventId, res);
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
res.json(bundles.map(b => ({
|
||||
...shape(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a bundle deal
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const createBundle = async (req, res) => {
|
||||
try {
|
||||
const event = await eventOr404(req.params.eventId, res);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.create({
|
||||
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Edit a bundle deal (including switching it off). Registrations that already
|
||||
// have it are only re-worked when their own items change.
|
||||
// @route PUT /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const updateBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(shape(bundle));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
|
||||
// registrations that got it keep their price.
|
||||
// @route DELETE /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) {
|
||||
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
} else {
|
||||
await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
|
||||
targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(used > 0
|
||||
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true, message: 'Bundle deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The bundle deals a basket gets, before any coupon code
|
||||
// @route POST /api/bundles/quote
|
||||
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
const quoteBundles = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, forUserId } = req.body || {};
|
||||
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { buyer } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
res.json({
|
||||
subtotal,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
total: round2(Math.max(0, subtotal - bundles.discount)),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** The deals to show on an event's page: available bundles, without internal fields. */
|
||||
async function publicBundlesForEvent(eventId) {
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId, isActive: true },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const now = new Date();
|
||||
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
|
||||
const s = shape(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent: deals.publicBundlesForEvent };
|
||||
|
||||
@@ -1,193 +1,28 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
// Campuses (v1). The logic is in services/campuses.ts, shared with /api/v2 (1.51.9).
|
||||
const campuses = require('../services/campuses');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const audit = (req, action, campus, metadata) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
// A slug that's already taken → a message instead of a Prisma error.
|
||||
function friendlyError(e) {
|
||||
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
|
||||
return e.message || safeErrorMessage(e);
|
||||
}
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
};
|
||||
|
||||
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
|
||||
// @route GET /api/campuses
|
||||
// @access Public
|
||||
const getCampuses = async (req, res) => {
|
||||
try {
|
||||
const enabled = await campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc A campus landing page: the campus (active only)
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
// @access Public
|
||||
const getCampusBySlug = async (req, res) => {
|
||||
try {
|
||||
const campus = (await campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
|
||||
// @route GET /api/campuses/admin
|
||||
// @access Private/Admin
|
||||
const getCampusesAdmin = async (req, res) => {
|
||||
try {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
|
||||
// @route PUT /api/campuses/settings body: { enabled }
|
||||
// @access Private/Admin
|
||||
const updateCampusSettings = async (req, res) => {
|
||||
try {
|
||||
const enabled = req.body?.enabled === true;
|
||||
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
|
||||
res.json({ enabled });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Add a campus (added at the end of the list)
|
||||
// @route POST /api/campuses
|
||||
// @access Private/Admin
|
||||
const createCampus = async (req, res) => {
|
||||
try {
|
||||
const { data, error } = cleanCampusInput(req.body);
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(req, 'campus_created', campus, { slug: campus.slug });
|
||||
res.status(201).json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change a campus: details, summary recipients, archived or not
|
||||
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
|
||||
// @access Private/Admin
|
||||
const updateCampus = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return res.status(404).json({ message: 'Campus not found' });
|
||||
const { data, error } = cleanCampusInput(req.body, { partial: true });
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
|
||||
if (Array.isArray(req.body?.notifyRecipientIds)) {
|
||||
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange = null;
|
||||
if (req.body?.archived !== undefined) {
|
||||
const archived = req.body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(req, 'campus_updated', campus, { changed });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put the campuses in this order (filters and lists follow it)
|
||||
// @route PUT /api/campuses/order body: { ids: [...] }
|
||||
// @access Private/Admin
|
||||
const reorderCampuses = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
|
||||
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
|
||||
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
|
||||
// @route DELETE /api/campuses/:id
|
||||
// @access Private/Admin
|
||||
const deleteCampus = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(req, 'campus_deleted', campus, { slug: campus.slug });
|
||||
res.json({ message: 'Campus deleted' });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
// ?registrants=true) people registered for X's events in the last 12 months. The
|
||||
// composer sends to these ids as usual, so news consent is still checked on sending.
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
// @access Private/Supervisor
|
||||
const getCampusAudience = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ campus, users });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
// @route GET /api/campuses — whether they're on, and the active ones
|
||||
const getCampuses = async (req, res) => { try { res.json(await campuses.publicCampuses()); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
const getCampusBySlug = async (req, res) => { try { res.json(await campuses.campusBySlug(req.params.slug)); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/admin
|
||||
const getCampusesAdmin = async (req, res) => { try { res.json(await campuses.adminCampuses()); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/settings { enabled }
|
||||
const updateCampusSettings = async (req, res) => { try { res.json(await campuses.setCampusesEnabled(req.body?.enabled === true, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/campuses
|
||||
const createCampus = async (req, res) => { try { res.status(201).json(await campuses.createCampus(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/:id
|
||||
const updateCampus = async (req, res) => { try { res.json(await campuses.updateCampus(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/order { ids }
|
||||
const reorderCampuses = async (req, res) => { try { await campuses.reorderCampuses(req.body?.ids); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/campuses/:id
|
||||
const deleteCampus = async (req, res) => { try { await campuses.deleteCampus(req.params.id, req.user, await ctxOf(req)); res.json({ message: 'Campus deleted' }); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
const getCampusAudience = async (req, res) => { try { res.json(await campuses.campusAudience(req.params.id, req.query.registrants === 'true', await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
|
||||
|
||||
@@ -1,392 +1,37 @@
|
||||
const prisma = require('../config/db');
|
||||
// Coupon codes (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
|
||||
const deals = require('../services/deals');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { listWhere } = require('../utils/campusScope');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundle } = require('../utils/bundles');
|
||||
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
|
||||
const dateOrNull = (v) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
|
||||
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope, eventIds) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
const optionalInt = (v, label, min = 0) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the relation lists. On update,
|
||||
* fields left out of the body are left alone.
|
||||
*/
|
||||
async function parseCouponBody(body, { isCreate }) {
|
||||
const data = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
|
||||
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
}
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
|
||||
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes = body.options !== undefined
|
||||
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
|
||||
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({
|
||||
where: { id: { in: scopes.map(s => s.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) throw new Error('One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
|
||||
throw new Error('Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
const out = {};
|
||||
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
// @desc List coupons with their use counts
|
||||
// @route GET /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
|
||||
.filter(c => couponAllowed(scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
res.json(coupons.map(c => ({
|
||||
...c,
|
||||
summary: describeCoupon(c),
|
||||
uses: byId[c.id]?._count?._all || 0,
|
||||
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get one coupon with its redemptions
|
||||
// @route GET /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const getCoupon = async (req, res) => {
|
||||
try {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a coupon
|
||||
// @route POST /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const createCoupon = async (req, res) => {
|
||||
try {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a coupon (including turning it off). Discounts already applied keep
|
||||
// their locked amount.
|
||||
// @route PUT /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
const scope = await scopeFor(req);
|
||||
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
}
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...relationWrites(parsed, false) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(req.body || {});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role,
|
||||
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, changedKeys },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
const { usesTotal } = await countCouponUses(coupon.id, null);
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
async function basketBuyer(req, forUserId) {
|
||||
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
const registrationOptions = [];
|
||||
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
|
||||
// sessions ticked is still one line at one price).
|
||||
const { isPickOption } = require('../utils/sessions');
|
||||
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
return buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
// @desc Check a code against the items someone is about to register for
|
||||
// @route POST /api/coupons/validate
|
||||
// @access Public (login needed for restricted codes)
|
||||
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
|
||||
// attendee — restricted codes are then checked when the registration is saved.
|
||||
// @route GET /api/coupons — with use counts
|
||||
const listCoupons = async (req, res) => { try { res.json(await deals.listCoupons(await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/coupons/:id — with its redemptions
|
||||
const getCoupon = async (req, res) => { try { res.json(await deals.getCoupon(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/coupons
|
||||
const createCoupon = async (req, res) => { try { res.status(201).json(await deals.createCoupon(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/coupons/:id
|
||||
const updateCoupon = async (req, res) => { try { res.json(await deals.updateCoupon(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/coupons/validate — a code against a basket ({ valid: false } answers 400, as before)
|
||||
const validateCode = async (req, res) => {
|
||||
try {
|
||||
const { code, eventId, options, forUserId } = req.body || {};
|
||||
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400); throw new Error('code, eventId and options are required');
|
||||
}
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
|
||||
const { buyer, isStaff } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount, bundles } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
res.json({
|
||||
valid: true,
|
||||
code: coupon.code,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
res.status(400).json({ valid: false, message: e.message });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
const result = await deals.checkCode(req.body || {}, actorOf(req));
|
||||
res.status(result.valid ? 200 : 400).json(result);
|
||||
} catch (e) { fail(res, e); }
|
||||
};
|
||||
|
||||
// @desc Coupon and bundle-deal uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to= — the Discounts report
|
||||
const listRedemptions = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json([
|
||||
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id,
|
||||
kind: 'bundle',
|
||||
uses: b.count,
|
||||
discountAmount: b.discount,
|
||||
createdAt: b.registration.createdAt,
|
||||
summary: describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name },
|
||||
user: b.registration.user,
|
||||
appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
]);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await deals.listRedemptions({ eventIds, from: dateOrNull(req.query.from), to: dateOrNull(req.query.to) }, await listWhere(req, 'event')));
|
||||
} catch (e) { fail(res, e); }
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +1,24 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
// Season passes (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
|
||||
const series = require('../services/series');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { scopeFor, canManage } = require('../utils/campusScope');
|
||||
const passes = require('../utils/seasonPass');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const audit = (req, pass, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
|
||||
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
async function assertCampuses(req, campusIds) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && campusIds.some(c => !canManage(scope, c))) {
|
||||
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// @desc A series' passes with their events, shares, and how many were sold
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
// @access Private/Supervisor
|
||||
const listPasses = async (req, res) => {
|
||||
try {
|
||||
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
|
||||
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
res.json(rows.map(p => ({
|
||||
...passes.passView(p),
|
||||
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
|
||||
// @access Private/Supervisor
|
||||
const createPass = async (req, res) => {
|
||||
try {
|
||||
const seriesId = String(req.body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
|
||||
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
|
||||
await assertCampuses(req, items.map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({
|
||||
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
|
||||
});
|
||||
audit(req, pass, 'created', { events: items.length, price: pass.price });
|
||||
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Change a pass. Its events can only change while nobody has bought it.
|
||||
// @route PUT /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const updatePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
|
||||
}
|
||||
if (items) await assertCampuses(req, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
res.json(passes.passView(await passes.loadPass(current.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a pass nobody has bought (otherwise switch it off)
|
||||
// @route DELETE /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const deletePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
|
||||
}
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
// @access Private
|
||||
const buy = async (req, res) => {
|
||||
try {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
|
||||
const back = `${frontendUrl()}/series/${pass.series.slug}`;
|
||||
const result = await passes.buyPass(req.params.id, req.user.id, {
|
||||
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
|
||||
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Pay what's still owed on a purchase (a new checkout)
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
// @access Private (the buyer)
|
||||
const payPurchase = async (req, res) => {
|
||||
try {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
|
||||
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
|
||||
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
|
||||
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc The signed-in person's pass purchases
|
||||
// @route GET /api/season-passes/mine
|
||||
// @access Private
|
||||
const myPurchases = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.seasonPassPurchase.findMany({
|
||||
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
const listPasses = async (req, res) => { try { res.json(await series.listPasses(req.query.seriesId ? String(req.query.seriesId) : undefined)); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes
|
||||
const createPass = async (req, res) => { try { res.status(201).json(await series.createPass(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/season-passes/:id (events and price fixed once someone has bought it)
|
||||
const updatePass = async (req, res) => { try { res.json(await series.updatePass(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/season-passes/:id
|
||||
const deletePass = async (req, res) => { try { await series.deletePass(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
const buy = async (req, res) => { try { res.status(201).json(await series.buyPass(req.params.id, req.user.id, { quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
const payPurchase = async (req, res) => { try { res.json(await series.payPassPurchase(req.params.id, req.user.id, { provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/season-passes/mine
|
||||
const myPurchases = async (req, res) => { try { res.json(await series.myPassPurchases(req.user.id)); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
|
||||
|
||||
@@ -1,126 +1,25 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
// Event series (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
|
||||
const series = require('../services/series');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
|
||||
const { scopeFor, canView, eventWhere } = require('../utils/campusScope');
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
|
||||
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
|
||||
});
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const audit = (req, action, series, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
|
||||
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// @desc Every series, with its events (the ones this person may see) and number of followers
|
||||
// @route GET /api/series
|
||||
// @access Private/Supervisor
|
||||
const listSeries = async (req, res) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
|
||||
res.json(rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => canView(scope, e.campusId)),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Create a series
|
||||
// @route POST /api/series
|
||||
// @access Private/Supervisor
|
||||
const createSeries = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
|
||||
audit(req, 'series_created', series);
|
||||
res.status(201).json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Rename a series or change its page
|
||||
// @route PUT /api/series/:id
|
||||
// @access Private/Supervisor
|
||||
const updateSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
const data = cleanSeriesInput(req.body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
res.json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a series (its events stay, just without a series; followers are dropped)
|
||||
// @route DELETE /api/series/:id
|
||||
// @access Private/Admin
|
||||
const deleteSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
return res.status(409).json({ message: 'People have bought season passes for this series, so it can\'t be deleted.' });
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc A series' public page: its details, upcoming and past public events, and whether the
|
||||
// signed-in person follows it
|
||||
// @route GET /api/series/by-slug/:slug
|
||||
// @access Public
|
||||
const getSeriesBySlug = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.findUnique({
|
||||
where: { slug: String(req.params.slug) },
|
||||
select: { id: true, name: true, slug: true, description: true, picture: true },
|
||||
});
|
||||
if (!series) return res.status(404).json({ message: 'Series not found' });
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = splitEvents(events);
|
||||
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
|
||||
// Season passes switched on (1.47.3); `unavailable` says why one can't be bought now.
|
||||
const { passView, PASS_INCLUDE } = require('../utils/seasonPass');
|
||||
const passes = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: PASS_INCLUDE }))
|
||||
.map(p => passView(p))
|
||||
.map(({ items, ...p }) => ({ ...p, items: items.map(({ campusId, ...i }) => i) }));
|
||||
res.json({ ...series, upcoming, past, following, passes });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
|
||||
// (the events this person may see), oldest first, with the change from the one before
|
||||
// @route GET /api/series/:id/compare
|
||||
// @access Private/Supervisor
|
||||
const compare = async (req, res) => {
|
||||
try {
|
||||
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
|
||||
// @access Private
|
||||
const followSeries = async (req, res) => {
|
||||
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
const unfollowSeries = async (req, res) => {
|
||||
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
// @route GET /api/series — every series, its events (the ones this person may see) and followers
|
||||
const listSeries = async (req, res) => { try { res.json(await series.listSeries(await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/series
|
||||
const createSeries = async (req, res) => { try { res.status(201).json(await series.createSeries(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/series/:id
|
||||
const updateSeries = async (req, res) => { try { res.json(await series.updateSeries(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/series/:id (events stay, without a series; followers are dropped)
|
||||
const deleteSeries = async (req, res) => { try { await series.deleteSeries(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/series/by-slug/:slug — the public page
|
||||
const getSeriesBySlug = async (req, res) => { try { res.json(await series.seriesPage(req.params.slug, req.user?.id || null)); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/series/:id/compare — year on year
|
||||
const compare = async (req, res) => { try { res.json(await series.compareSeries(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST|DELETE /api/series/:id/follow
|
||||
const followSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, true)); } catch (e) { fail(res, e); } };
|
||||
const unfollowSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, false)); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -25,10 +25,12 @@ function hubUrl() {
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
constructor(message, code, status, data = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
// The Hub's whole error body (AI errors carry the new AI status, protocol §3b).
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function read(keys) {
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
async function post(path, body, headers = {}, timeoutMs = TIMEOUT_MS) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
@@ -61,18 +63,21 @@ async function post(path, body, headers = {}) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
|
||||
async function signedPost(path, body) {
|
||||
/**
|
||||
* POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection.
|
||||
* AI calls wait longer than the default (Claude can take a while to write).
|
||||
*/
|
||||
async function signedPost(path, body, { timeoutMs = TIMEOUT_MS } = {}) {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
|
||||
const secret = decrypt(s.hub_install_secret);
|
||||
@@ -83,7 +88,7 @@ async function signedPost(path, body) {
|
||||
'X-Hub-Timestamp': timestamp,
|
||||
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
|
||||
};
|
||||
});
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
/** Register this install with a licence key. Returns the licence claims. */
|
||||
@@ -211,4 +216,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, signedPost, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
const { licenceStatus } = require('./licence');
|
||||
|
||||
// v1 paths (until 2.0 removes them) and their /api/v2 successors (1.52.3: the screens have used v2
|
||||
// since 1.51.15, so without these a suspended or unlicensed site wasn't read-only any more).
|
||||
const BLOCKED = [
|
||||
['POST', /^\/api\/events\/?$/],
|
||||
['PUT', /^\/api\/events\/[^/]+\/?$/],
|
||||
@@ -38,6 +40,32 @@ const BLOCKED = [
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
|
||||
// /api/v2 — the same actions.
|
||||
['POST', /^\/api\/v2\/events\/?$/],
|
||||
['PATCH', /^\/api\/v2\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/copy-from\/?$/],
|
||||
['POST', /^\/api\/v2\/series\/?$/],
|
||||
['PATCH', /^\/api\/v2\/series\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/season-passes\/?$/],
|
||||
['PATCH', /^\/api\/v2\/season-passes\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/season-passes\/[^/]+\/buy\/?$/],
|
||||
['POST', /^\/api\/v2\/season-pass-purchases\/[^/]+\/checkout\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/ticket-options\/?$/],
|
||||
['PATCH', /^\/api\/v2\/ticket-options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/ticket-options\/[^/]+\/variants\/?$/],
|
||||
['PATCH', /^\/api\/v2\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/v2\/registrations\/[^/]+\/items\/?$/],
|
||||
['POST', /^\/api\/v2\/payments\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/[^/]+\/(checkout|payment-link(\/send)?)\/?$/],
|
||||
['POST', /^\/api\/v2\/payment-links\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/waitlist\/?$/],
|
||||
['POST', /^\/api\/v2\/coupons\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/bundles\/?$/],
|
||||
['POST', /^\/api\/v2\/volunteer-roles\/[^/]+\/(sign-up|assign)\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/volunteer-roles\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
|
||||
@@ -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,156 @@
|
||||
/**
|
||||
* Campuses (1.51.9): the public list and landing pages, Site settings → Campuses, and "everyone at
|
||||
* campus X" for messages. Moved from campusController.js so v1 and /api/v2 share it.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const campusesUtil = require('../utils/campuses') as {
|
||||
SETTING_KEY: string;
|
||||
CAMPUS_PUBLIC_SELECT: Record<string, true>;
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
cleanCampusInput(body: any, opts?: { partial?: boolean }): { data: Record<string, any>; error?: string };
|
||||
campusAudienceWhere(campusId: string, opts: { includeRegistrants: boolean }): object;
|
||||
};
|
||||
const settingsCache = require('../utils/settingsCache') as { invalidate(): void; warmCache(): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: { campusIds: string[] } | null; ip?: string };
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
} as const;
|
||||
|
||||
const audit = (actor: Actor, ctx: Ctx, action: string, campus: { id?: string; name?: string } | null, metadata: object) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** A taken web address → a message instead of a database error. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another campus already uses that web address — choose a different one.');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The campus could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether campuses are on, and the active ones in order (public filters, event cards). */
|
||||
export async function publicCampuses() {
|
||||
const enabled = await campusesUtil.campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** A campus landing page (active campuses, while campuses are on). */
|
||||
export async function campusBySlug(slug: string) {
|
||||
const campus = (await campusesUtil.campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(slug), archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
return campus;
|
||||
}
|
||||
|
||||
/** Every campus, archived ones last, with event counts and summary recipients. */
|
||||
export async function adminCampuses() {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesUtil.campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** Campuses on or off (off hides campus fields and filters; nothing is deleted). */
|
||||
export async function setCampusesEnabled(enabled: boolean, actor: Actor, ctx: Ctx) {
|
||||
await prisma.appSetting.upsert({ where: { key: campusesUtil.SETTING_KEY }, update: { value: String(enabled) }, create: { key: campusesUtil.SETTING_KEY, value: String(enabled) } });
|
||||
settingsCache.invalidate();
|
||||
await settingsCache.warmCache();
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: ctx.ip });
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
/** A new campus, at the end of the list. */
|
||||
export function createCampus(body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body);
|
||||
if (error) fail(400, error);
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...(data as any), order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(actor, ctx, 'campus_created', campus, { slug: campus.slug });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a campus: details, who gets its summaries (staff only), archived or not. */
|
||||
export function updateCampus(campusId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: campusId } });
|
||||
if (!existing) return fail(404, 'Campus not found');
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body, { partial: true });
|
||||
if (error) fail(400, error);
|
||||
if (Array.isArray(body?.notifyRecipientIds)) {
|
||||
const ids = body.notifyRecipientIds.filter((x: unknown) => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange: boolean | null = null;
|
||||
if (body?.archived !== undefined) {
|
||||
const archived = body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(actor, ctx, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(actor, ctx, 'campus_updated', campus, { changed });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts the campuses in this order (filters and lists follow it). */
|
||||
export async function reorderCampuses(ids: string[]) {
|
||||
const clean = (Array.isArray(ids) ? ids : []).filter(x => typeof x === 'string');
|
||||
if (!clean.length) fail(400, 'Send the campus ids in their new order.');
|
||||
await prisma.$transaction(clean.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
}
|
||||
|
||||
/** Deletes a campus with no events and no staff limited to it (otherwise archive it). */
|
||||
export async function deleteCampus(campusId: string, actor: Actor, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
fail(409, `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.`, 'CAMPUS_HAS_SCOPED_STAFF');
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
fail(409, `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.`, 'CAMPUS_HAS_EVENTS');
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(actor, ctx, 'campus_deleted', campus, { slug: campus.slug });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
* `includeRegistrants`) people registered for X's events in the last 12 months. News consent is
|
||||
* still checked when sending. Campus-scoped staff only get their own campuses.
|
||||
*/
|
||||
export async function campusAudience(campusId: string, includeRegistrants: boolean, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, select: { id: true, name: true } });
|
||||
if (!campus || (ctx.scope && !ctx.scope.campusIds.includes(campus.id))) return fail(404, 'Campus not found');
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusesUtil.campusAudienceWhere(campus.id, { includeRegistrants }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { campus, users };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Bundle deals and coupon codes (1.51.9). Moved from bundleController.js and couponController.js
|
||||
* so v1 and /api/v2 share them; matching codes and working out discounts stays in
|
||||
* utils/coupons.js and utils/bundles.js.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const bundlesUtil = require('../utils/bundles') as {
|
||||
KINDS: string[];
|
||||
describeBundle(b: any): string;
|
||||
describeBundleItems(b: any): string;
|
||||
isBundleAvailable(b: any, now: Date): boolean;
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
normalizeCode(code: unknown): string;
|
||||
previewCoupon(args: object): Promise<{ coupon: any; discount: number; bundles: { discount: number; applications: any[] } }>;
|
||||
previewBundles(eventId: string, lines: any[]): Promise<{ discount: number; applications: any[] }>;
|
||||
buildCouponLines(reg: any): any[];
|
||||
describeCoupon(c: any): string;
|
||||
countCouponUses(couponId: string, userId: string | null): Promise<{ usesTotal: number }>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as { couponAllowed(scope: object | null, campusIds: Array<string | null>): boolean };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role; email?: string };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
const round2 = (n: unknown) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n: number) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** Plain errors from the parsers and helpers are the caller's mistake (400), like v1. */
|
||||
async function clientErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'That could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const optionalDate = (v: unknown, label: string): Date | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(String(v));
|
||||
if (isNaN(d.getTime())) fail(400, `${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
// ─── Pricing a basket (bundle quotes and coupon checks) ──────────────────────
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the signed-in buyer, or — for staff — the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
export async function basketBuyer(actor: Actor | null, forUserId: string | null | undefined) {
|
||||
const isStaff = !!(actor && STAFF_ROLES.includes(actor.role));
|
||||
let buyer: { id: string; role: string; email?: string } | null = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
if (isStaff && forUserId) buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
else if (isStaff && forUserId === null) buyer = null;
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
export type BasketLine = { eventOptionId: string; variantId?: string | null; quantity: number; sessionIds?: string[] };
|
||||
|
||||
/** Prices a basket the way registering will, as coupon/bundle lines. */
|
||||
export async function priceBasket(event: { eventOptions: any[] } & Record<string, any>, options: BasketLine[], buyer: any) {
|
||||
const registrationOptions: any[] = [];
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const { isPickOption } = require('../utils/sessions') as { isPickOption(ev: any, opt: any): boolean };
|
||||
const perSession = (o: BasketLine) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId ? await pricing.resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await pricing.resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }] });
|
||||
}
|
||||
return coupons.buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
const basketEvent = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** The bundle deals a basket gets, before any coupon. */
|
||||
export function quoteBundles(input: { eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null) {
|
||||
return clientErrors(async () => {
|
||||
if (!input.eventId || !Array.isArray(input.options)) fail(400, 'eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = round2(lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0));
|
||||
const b = await coupons.previewBundles(event.id, lines);
|
||||
return { subtotal, bundleDiscount: b.discount, bundles: b.applications, total: round2(Math.max(0, subtotal - b.discount)) };
|
||||
});
|
||||
}
|
||||
|
||||
export type CodeCheck =
|
||||
| { valid: true; code: string; type: string; value: number; summary: string; description: string | null; discount: number; bundleDiscount: number; bundles: any[]; subtotal: number; total: number }
|
||||
| { valid: false; message: string };
|
||||
|
||||
/**
|
||||
* Checks a code against a basket. Staff can check on an attendee's behalf (`forUserId`), or
|
||||
* leave it out for a new attendee — restricted codes are then checked when they register.
|
||||
*/
|
||||
export function checkCode(input: { code?: string; eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null): Promise<CodeCheck> {
|
||||
return clientErrors(async () => {
|
||||
if (!input.code || !input.eventId || !Array.isArray(input.options) || input.options.length === 0) fail(400, 'code, eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer, isStaff } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0);
|
||||
try {
|
||||
const { coupon, discount, bundles } = await coupons.previewCoupon({ code: input.code, eventId: event.id, user: buyer, lines, deferRestrictions: isStaff && !buyer });
|
||||
return {
|
||||
valid: true as const, code: coupon.code, type: coupon.type, value: coupon.value, summary: coupons.describeCoupon(coupon), description: coupon.description,
|
||||
discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (!e.isCouponError) throw e;
|
||||
return { valid: false as const, message: e.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i: any) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find((v: any) => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
|
||||
/** What the deal is worth at standard prices, and a short "save …" line (or null when the headline says it). */
|
||||
function valueOf(bundle: any): { standardValue: number | null; savingText: string | null } {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m: number, i: any) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s: number, i: any) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
function shapeBundle(bundle: any, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }: any) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return { ...bundle, items, summary: bundlesUtil.describeBundleItems(bundle), headline: bundlesUtil.describeBundle(bundle), ...valueOf(bundle), available: bundlesUtil.isBundleAvailable(bundle, now) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A create/update body as Prisma data plus the item list. On update, fields left out stay as
|
||||
* they are; when any pricing field is sent, the whole deal (saved + new values) is checked.
|
||||
*/
|
||||
async function parseBundleBody(body: any, eventId: string, { isCreate, current = null }: { isCreate: boolean; current?: any }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) fail(400, 'Give the bundle a name');
|
||||
if (name.length > 80) fail(400, 'Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Available from" must be before "Available until"');
|
||||
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items: undefined };
|
||||
|
||||
const pick = (f: string) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!bundlesUtil.KINDS.includes(kind)) fail(400, 'Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map<string, { eventOptionId: string; variantId: string | null; quantity: number }>();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) fail(400, 'Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
const items = [...merged.values()];
|
||||
if (items.length === 0) fail(400, kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) fail(400, 'A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) fail(400, 'At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) fail(400, 'Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) fail(400, 'Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) fail(400, 'Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) fail(400, 'Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: items.map(i => i.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) fail(400, 'One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row!.variants.some(v => v.id === i.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
const eventOr404 = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** An event's bundle deals with how often each has been used and the discount given. */
|
||||
export async function listBundles(eventId: string) {
|
||||
await eventOr404(eventId);
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
return bundles.map(b => ({
|
||||
...shapeBundle(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createBundle(eventId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const event = await eventOr404(eventId);
|
||||
const parsed = await parseBundleBody(body || {}, event.id, { isCreate: true });
|
||||
const bundle = await prisma.eventBundle.create({ data: { ...(parsed.data as any), eventId: event.id, items: { create: parsed.items } }, include: DETAIL_INCLUDE });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_created', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: bundlesUtil.describeBundle(bundle) }, ip: ctx.ip,
|
||||
});
|
||||
return { ...shapeBundle(bundle), uses: 0, registrations: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Edits a deal (or switches it off). Registrations that have it are re-worked only when their own items change. */
|
||||
export function updateBundle(bundleId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId }, include: { items: true } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const parsed = await parseBundleBody(body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_updated', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(body || {}) }, ip: ctx.ip,
|
||||
});
|
||||
return shapeBundle(bundle);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a deal; one that's been used is switched off instead, so registrations keep their price. */
|
||||
export async function deleteBundle(bundleId: string, actor: Actor, ctx: Ctx) {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
else await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_deleted', targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 }, ip: ctx.ip,
|
||||
});
|
||||
return used > 0
|
||||
? { switchedOff: true as const, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true as const, message: 'Bundle deleted' };
|
||||
}
|
||||
|
||||
/** The deals shown on an event's page: available bundles, without internal fields. */
|
||||
export async function publicBundlesForEvent(eventId: string) {
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId, isActive: true }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const now = new Date();
|
||||
return bundles.filter(b => bundlesUtil.isBundleAvailable(b, now)).map(b => {
|
||||
const s = shapeBundle(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map((i: any) => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses.
|
||||
const couponCampuses = (coupon: any): Array<string | null> => (coupon.events || []).map((ce: any) => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope: object | null, eventIds: string[] | undefined) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && campusScope.couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
|
||||
const optionalInt = (v: unknown, label: string, min = 0): number | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) fail(400, `${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** A create/update body as Prisma data plus the event, ticket and person lists. On update, fields left out stay. */
|
||||
async function parseCouponBody(body: any, { isCreate }: { isCreate: boolean }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = coupons.normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) fail(400, 'Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) fail(400, "Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) fail(400, 'Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) fail(400, 'A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some((r: string) => !ROLES.includes(r))) fail(400, 'Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) fail(400, 'Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
const eventIds: string[] | undefined = body.eventIds !== undefined ? [...new Set<string>((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes: Array<{ eventOptionId: string; variantId: string | null }> | undefined = body.options !== undefined
|
||||
? (body.options || []).filter((o: any) => o && o.eventOptionId).map((o: any) => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds: string[] | undefined = body.userIds !== undefined ? [...new Set<string>((body.userIds || []).filter(Boolean))] : undefined;
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) return fail(400, 'One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) fail(400, 'Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }: Awaited<ReturnType<typeof parseCouponBody>>, isCreate: boolean) {
|
||||
const out: Record<string, any> = {};
|
||||
const replace = isCreate ? {} : { deleteMany: {} };
|
||||
if (eventIds !== undefined) out.events = { ...replace, create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...replace, create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...replace, create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Codes the caller may see, with how often each has been used and the discount given. */
|
||||
export async function listCoupons(ctx: Ctx) {
|
||||
const list = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } })).filter(c => campusScope.couponAllowed(ctx.scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
return list.map(c => ({ ...c, summary: coupons.describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0 }));
|
||||
}
|
||||
|
||||
/** One code with its redemptions. */
|
||||
export async function getCoupon(couponId: string, ctx: Ctx) {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: couponId },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !campusScope.couponAllowed(ctx.scope, couponCampuses(coupon))) return fail(404, 'Coupon not found');
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon) };
|
||||
}
|
||||
|
||||
export function createCoupon(body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const parsed = await parseCouponBody(body || {}, { isCreate: true });
|
||||
if (!(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
const coupon = await prisma.coupon.create({ data: { ...(parsed.data as any), createdById: actor.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE });
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: ctx.ip });
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a code (or turns it off). Discounts already applied keep their locked amount; a used code can't be renamed. */
|
||||
export function updateCoupon(couponId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: couponId }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
if (!existing || !campusScope.couponAllowed(ctx.scope, couponCampuses(existing))) return fail(404, 'Coupon not found');
|
||||
const parsed = await parseCouponBody({ type: existing.type, ...body }, { isCreate: false });
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
if (await prisma.couponRedemption.count({ where: { couponId: existing.id } }) > 0) fail(400, 'This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.');
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE });
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(body || {});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: ctx.ip,
|
||||
});
|
||||
const { usesTotal } = await coupons.countCouponUses(coupon.id, null);
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: usesTotal };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and bundle-deal uses for the Discounts report, newest first. `scopeWhere` limits it to
|
||||
* the caller's campuses (`{ event: … }` or `{}`). Bundle dates go by when the registration was made.
|
||||
*/
|
||||
export async function listRedemptions(opts: { eventIds: string[]; from: Date | null; to: Date | null }, scopeWhere: object) {
|
||||
const range = (opts.from || opts.to) ? { createdAt: { ...(opts.from ? { gte: opts.from } : {}), ...(opts.to ? { lte: opts.to } : {}) } } : {};
|
||||
const events = opts.eventIds.length ? { eventId: { in: opts.eventIds } } : {};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' }, ...events, ...scopeWhere }, ...range },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...events, ...range, ...scopeWhere } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
return [
|
||||
...rows.map(r => ({ ...r, kind: 'coupon' as const, uses: 1, summary: coupons.describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id, kind: 'bundle' as const, uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: bundlesUtil.describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
import { SESSIONS_INCLUDE, SERIES_SELECT, withSchedule } from './events';
|
||||
import { publicBundlesForEvent } from './deals';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const sessionsUtil = require('../utils/sessions') as {
|
||||
@@ -106,7 +107,6 @@ export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: Url
|
||||
}));
|
||||
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const { publicBundlesForEvent } = require('../controllers/bundleController') as { publicBundlesForEvent(id: string): Promise<unknown[]> };
|
||||
const { nextInSeries } = require('../utils/series') as { nextInSeries(ev: unknown): Promise<unknown> };
|
||||
return withSchedule({
|
||||
...event,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Event series and season passes (1.51.9). Moved from seriesController.js and
|
||||
* seasonPassController.js so v1 and /api/v2 share them; the rules themselves stay in
|
||||
* utils/series.js and utils/seasonPass.js.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const seriesUtil = require('../utils/series') as {
|
||||
cleanSeriesInput(body: any, current?: any): any;
|
||||
publicEventWhere(now?: Date): object;
|
||||
splitEvents(events: any[]): { upcoming: any[]; past: any[] };
|
||||
follow(seriesId: string, userId: string): Promise<unknown>;
|
||||
unfollow(seriesId: string, userId: string): Promise<unknown>;
|
||||
compareSeries(seriesId: string, opts: { scopeWhere: object }): Promise<unknown>;
|
||||
};
|
||||
const passes = require('../utils/seasonPass') as {
|
||||
PASS_INCLUDE: any;
|
||||
passView(pass: any, now?: Date): any;
|
||||
cleanPassInput(body: any, seriesId: string, current?: any): Promise<{ data: any; items: any[] | undefined }>;
|
||||
loadPass(id: string): Promise<any>;
|
||||
buyPass(passId: string, userId: string, opts: object): Promise<any>;
|
||||
startPassCheckout(purchaseId: string, userId: string, opts: object): Promise<any>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as {
|
||||
canView(scope: object | null, campusId: string | null): boolean;
|
||||
canManage(scope: object, campusId: string | null): boolean;
|
||||
eventWhere(scope: object | null): object;
|
||||
};
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
/** A taken web address is a CONFLICT with a message people understand. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another series already uses that web address');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The series could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const auditSeries = (actor: Actor, ctx: Ctx, action: string, series: { id: string; name: string }, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'EventSeries', targetId: series.id, metadata: { name: series.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every series with its events and number of followers. A series spans campuses: campus-scoped
|
||||
* staff see every series, but only their campuses' events in it.
|
||||
*/
|
||||
export async function listSeries(ctx: Ctx) {
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
return rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => campusScope.canView(ctx.scope, e.campusId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSeries(body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const series = await prisma.eventSeries.create({ data: seriesUtil.cleanSeriesInput(body) });
|
||||
auditSeries(actor, ctx, 'series_created', series);
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSeries(seriesId: string, body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
const data = seriesUtil.cleanSeriesInput(body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
auditSeries(actor, ctx, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a series: its events stay (without a series) and followers are dropped. Not once passes are sold. */
|
||||
export async function deleteSeries(seriesId: string, actor: Actor, ctx: Ctx) {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
fail(409, 'People have bought season passes for this series, so it can\'t be deleted.');
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
auditSeries(actor, ctx, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
}
|
||||
|
||||
/** A series' public page: details, upcoming and past public events, its passes, and whether `userId` follows it. */
|
||||
export async function seriesPage(slug: string, userId: string | null) {
|
||||
const series = await prisma.eventSeries.findUnique({ where: { slug: String(slug) }, select: { id: true, name: true, slug: true, description: true, picture: true } });
|
||||
if (!series) return fail(404, 'Series not found');
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...seriesUtil.publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = seriesUtil.splitEvents(events);
|
||||
const following = userId ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId } } })) : false;
|
||||
// Passes switched on; `unavailable` says why one can't be bought now.
|
||||
const list = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE }))
|
||||
.map(p => passes.passView(p))
|
||||
.map(({ items, ...p }: any) => ({ ...p, items: items.map(({ campusId: _c, ...i }: any) => i) }));
|
||||
return { ...series, upcoming, past, following, passes: list };
|
||||
}
|
||||
|
||||
/** Year on year: each event in the series (that the caller may see), oldest first. */
|
||||
export function compareSeries(seriesId: string, ctx: Ctx) {
|
||||
return seriesUtil.compareSeries(seriesId, { scopeWhere: campusScope.eventWhere(ctx.scope) });
|
||||
}
|
||||
|
||||
export async function setFollowing(seriesId: string, userId: string, following: boolean) {
|
||||
try {
|
||||
if (following) await seriesUtil.follow(seriesId, userId);
|
||||
else await seriesUtil.unfollow(seriesId, userId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Could not change following');
|
||||
}
|
||||
return { following };
|
||||
}
|
||||
|
||||
// ─── Season passes ───────────────────────────────────────────────────────────
|
||||
|
||||
const auditPass = (actor: Actor, ctx: Ctx, pass: { id: string; name: string }, action: string, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id, metadata: { action, name: pass.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
function assertCampuses(ctx: Ctx, campusIds: Array<string | null>) {
|
||||
if (ctx.scope && campusIds.some(c => !campusScope.canManage(ctx.scope!, c))) {
|
||||
fail(403, 'You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
}
|
||||
}
|
||||
|
||||
const asServiceError = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Something went wrong with the pass');
|
||||
}
|
||||
};
|
||||
|
||||
/** A series' passes (or all) with their events, price shares, and how many were sold or are unpaid. */
|
||||
export async function listPasses(seriesId?: string) {
|
||||
const rows = await prisma.seasonPass.findMany({ where: seriesId ? { seriesId } : {}, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
const count = (passId: string, status: string) => sold.filter(s => s.passId === passId && s.status === status).reduce((n, s) => n + (s._sum.quantity || 0), 0);
|
||||
return rows.map(p => ({ ...passes.passView(p), sold: count(p.id, 'paid'), unpaid: count(p.id, 'pending') }));
|
||||
}
|
||||
|
||||
export function createPass(body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const seriesId = String(body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) fail(404, 'Series not found');
|
||||
const { data, items } = await passes.cleanPassInput(body, seriesId);
|
||||
assertCampuses(ctx, (items || []).map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({ data: { ...data, seriesId, items: { create: (items || []).map(({ campusId: _c, ...i }) => i) } } });
|
||||
auditPass(actor, ctx, pass, 'created', { events: (items || []).length, price: pass.price });
|
||||
return passes.passView(await passes.loadPass(pass.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a pass. Its events and price can't change once somebody has bought it. */
|
||||
export function updatePass(passId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
fail(409, 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.');
|
||||
}
|
||||
if (items) assertCampuses(ctx, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId: _c, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
auditPass(actor, ctx, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
return passes.passView(await passes.loadPass(current.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a pass nobody has bought (otherwise switch it off). */
|
||||
export function deletePass(passId: string, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) fail(409, 'People have bought this pass, so it can\'t be deleted — switch it off instead.');
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
auditPass(actor, ctx, current, 'deleted');
|
||||
});
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const backTo = (slug: string) => {
|
||||
const back = `${frontendUrl()}/series/${slug}`;
|
||||
return { successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed` };
|
||||
};
|
||||
|
||||
/** Buys passes: registers the buyer at every event on it, then a checkout (or tickets when it's free). */
|
||||
export function buyPass(passId: string, userId: string, opts: { quantity?: number; provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: passId }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return fail(404, 'Season pass not found');
|
||||
return passes.buyPass(passId, userId, { quantity: opts.quantity ?? 1, provider: opts.provider || null, ...backTo(pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
/** Pays what's still owed on a pass purchase (a new checkout). The buyer only. */
|
||||
export function payPassPurchase(purchaseId: string, userId: string, opts: { provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: purchaseId }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== userId) return fail(404, 'Purchase not found');
|
||||
return passes.startPassCheckout(purchaseId, userId, { provider: opts.provider || null, ...backTo(purchase.pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
export function myPassPurchases(userId: string) {
|
||||
return prisma.seasonPassPurchase.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const ACCESS_ACTIONS = {
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
ai_feedback: 'Feedback comments summarised by the AI helper',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
@@ -469,6 +469,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
// AI helper calls (2.0): kept for the site's monthly totals, no longer linked to them.
|
||||
await tx.aiCall.updateMany({ where: { userId }, data: { userId: null } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• If we use the AI helpers: our staff can ask an AI service to help draft event details and messages, improve text, and summarise reports and event feedback. A person checks every suggestion before anything is saved or sent. Only what a task needs is sent — never your name, contact details, or medical or children\'s information — and feedback comments are sent without who wrote them (see section 9).',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
'• Improve our events and website (using anonymous statistics, and only with your permission).',
|
||||
'• Comply with legal obligations.',
|
||||
@@ -166,6 +167,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we use the AI helpers, requests go through our software provider\'s service to Anthropic (the Claude AI service) in the United States, which is listed above. Our software provider does not keep what was sent. Anthropic handles it under its commercial terms, which do not allow it to be used to train AI; it may be kept for a short time for safety checks. Because this is outside South Africa, it is only done under the protections POPIA requires for such transfers.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
@@ -279,6 +281,14 @@ const BUILT_IN_UPDATES = [
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
{
|
||||
revision: '2.0.0',
|
||||
summary: [
|
||||
'AI helpers: staff can ask an AI service (Anthropic\'s Claude, through the software provider) to draft events and messages, improve text and summarise reports and feedback; a person checks every suggestion, and no names, contact details, medical or children\'s information are sent.',
|
||||
'Where AI requests go (the United States), that the provider doesn\'t keep them, and that they aren\'t used to train AI.',
|
||||
],
|
||||
sections: ['use', 'sharing'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
@@ -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,12 +46,308 @@ const SUCCESSORS: Successor[] = [
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
// 1.51.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
|
||||
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
|
||||
// 1.51.15: privacy and admin
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
|
||||
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
|
||||
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
|
||||
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
|
||||
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
|
||||
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
|
||||
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
|
||||
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
|
||||
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
|
||||
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
|
||||
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
|
||||
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
|
||||
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
|
||||
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
|
||||
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
|
||||
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
|
||||
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
|
||||
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
|
||||
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
|
||||
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
|
||||
// 1.51.14: people and messaging
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/reset$/, v2: () => '/api/v2/auth/password/reset' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/activate$/, v2: () => '/api/v2/auth/activate' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/revoke-sessions$/, v2: () => '/api/v2/me/sessions/revoke' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/close-account$/, v2: () => '/api/v2/me/close' },
|
||||
{ method: 'GET', v1: /^\/api\/users\/activity$/, v2: () => '/api/v2/me/activity' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/verify-password$/, v2: () => '/api/v2/me/verify-password' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/2fa\/(enable|confirm|disable)$/, v2: m => `/api/v2/me/two-factor/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/users\/check-exists$/, v2: () => '/api/v2/users/exists' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/revoke-sessions$/, v2: m => `/api/v2/users/${m[1]}/sessions/revoke` },
|
||||
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/anonymize$/, v2: m => `/api/v2/users/${m[1]}/anonymize` },
|
||||
{ method: 'GET', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/users' },
|
||||
// (/api/users/profile is /api/v2/me — see 1.51.2 below.)
|
||||
{ method: 'GET', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/users\/([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/admin\/all$/, v2: () => '/api/v2/admin/events' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees\/schedule$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees/schedule` },
|
||||
{ method: 'POST', v1: /^\/api\/broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/email/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp-broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/whatsapp/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/scheduled-emails\/?$/, v2: () => '/api/v2/scheduled-messages' },
|
||||
{ method: 'PATCH', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/automations\/schedule$/, v2: () => '/api/v2/automations/schedule' },
|
||||
{ method: 'GET', v1: /^\/api\/whatsapp\/(config|status|qr)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp\/config$/, v2: () => '/api/v2/whatsapp/config' },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp\/create-instance$/, v2: () => '/api/v2/whatsapp/instance' },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp\/delete-instance$/, v2: () => '/api/v2/whatsapp/instance' },
|
||||
{ method: 'POST', v1: /^\/api\/whatsapp\/(request-code|logout|start|restart)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
|
||||
{ method: 'POST', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
|
||||
{ method: 'GET', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
|
||||
{ method: 'POST', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
|
||||
{ method: 'PUT', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/dashboard\/(today|search)$/, v2: m => `/api/v2/dashboard/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/stats\/(staff|supervisor|admin|overview)$/, v2: m => `/api/v2/stats/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/analytics\/track$/, v2: () => '/api/v2/analytics/track' },
|
||||
{ method: 'GET', v1: /^\/api\/analytics\/funnel$/, v2: () => '/api/v2/analytics/funnel' },
|
||||
{ method: 'POST', v1: /^\/api\/reports\/(pdf|excel|email|whatsapp)$/, v2: m => `/api/v2/reports/${m[1]}` },
|
||||
// 1.51.13: kids check-in and volunteer rosters
|
||||
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'DELETE', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/me\/(children|adults)$/, v2: m => `/api/v2/me/family/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/setup-events$/, v2: () => '/api/v2/kids/setup-events' },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events$/, v2: () => '/api/v2/kids/events' },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/(settings|board|families|offline)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/kids/settings` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/(families|check-in|check-out|duty|sync)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/qr\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/qr/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/(parents|children|adults)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/${m[3]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/children\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/children/${m[3]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/code\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/code/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/check-ins\/([^/]+)\/(override|page)$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/check-ins\/([^/]+)\/room$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/room` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/alerts\/([^/]+)\/resolve$/, v2: m => `/api/v2/kids/alerts/${m[1]}/resolve` },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/me$/, v2: () => '/api/v2/me/volunteering' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/roles\/([^/]+)\/sign-up$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/sign-up` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/slots\/([^/]+)\/swap$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/swap` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/swaps\/([^/]+)$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/swaps\/([^/]+)\/accept$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}/accept` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/check-in$/, v2: () => '/api/v2/volunteering/check-in' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events$/, v2: () => '/api/v2/volunteering/events' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/hours$/, v2: () => '/api/v2/volunteering/hours' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteers` },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/(qr|settings)$/, v2: m => `/api/v2/events/${m[1]}/volunteers/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/volunteers/settings` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/roles$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
|
||||
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)\/assign$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/assign` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)\/check-in$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/check-in` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/remove` },
|
||||
// 1.51.12: tickets, scan locks, attendance and certificates, allocations
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/mytickets$/, v2: () => '/api/v2/me/tickets' },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/email$/, v2: () => '/api/v2/me/tickets/email' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/scans\/recent$/, v2: () => '/api/v2/tickets/scans/recent' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/scans\/stats$/, v2: () => '/api/v2/tickets/scans/stats' },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/send-to$/, v2: () => '/api/v2/tickets/send' },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/generate$/, v2: () => '/api/v2/tickets/generate' },
|
||||
{ method: 'PUT', v1: /^\/api\/tickets\/email-sent$/, v2: () => '/api/v2/tickets/email-sent' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/qr\/([^/]+)$/, v2: m => `/api/v2/tickets/qr/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/tickets` },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/transfer-offer\/([^/]+)\/(accept|decline)$/, v2: m => `/api/v2/ticket-transfers/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/?$/, v2: () => '/api/v2/tickets' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)\/transfers$/, v2: m => `/api/v2/tickets/${m[1]}/transfers` },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
|
||||
{ method: 'DELETE', v1: /^\/api\/tickets\/([^/]+)\/transfer$/, v2: m => `/api/v2/tickets/${m[1]}/transfer` },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/([^/]+)$/, v2: m => `/api/v2/tickets/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
|
||||
{ method: 'POST', v1: /^\/api\/scan-locks\/?$/, v2: () => '/api/v2/scan-locks' },
|
||||
{ method: 'GET', v1: /^\/api\/scan-locks\/reports$/, v2: () => '/api/v2/scan-locks/reports' },
|
||||
{ method: 'POST', v1: /^\/api\/scan-locks\/reports\/([^/]+)\/review$/, v2: m => `/api/v2/scan-locks/reports/${m[1]}/review` },
|
||||
{ method: 'GET', v1: /^\/api\/scan-locks\/([^/]+)\/snapshot$/, v2: m => `/api/v2/scan-locks/${m[1]}/snapshot` },
|
||||
{ method: 'POST', v1: /^\/api\/scan-locks\/([^/]+)\/(sync|force-release)$/, v2: m => `/api/v2/scan-locks/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/scan-locks\/([^/]+)$/, v2: m => `/api/v2/scan-locks/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/verify\/([^/]+)$/, v2: m => `/api/v2/certificates/verify/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/mine$/, v2: () => '/api/v2/me/certificates' },
|
||||
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/preview$/, v2: () => '/api/v2/certificates/preview' },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/certificates\/([^/]+)\/pdf$/, v2: m => `/api/v2/certificates/${m[1]}/pdf` },
|
||||
{ method: 'POST', v1: /^\/api\/attendance\/certificates\/([^/]+)\/revoke$/, v2: m => `/api/v2/certificates/${m[1]}/revoke` },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/export$/, v2: m => `/api/v2/events/${m[1]}/attendance/export` },
|
||||
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/mark$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
|
||||
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates$/, v2: m => `/api/v2/events/${m[1]}/certificates` },
|
||||
{ method: 'POST', v1: /^\/api\/attendance\/([^/]+)\/certificates\/send$/, v2: m => `/api/v2/events/${m[1]}/certificates/send` },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)\/certificates\.pdf$/, v2: m => `/api/v2/events/${m[1]}/certificates.pdf` },
|
||||
{ method: 'GET', v1: /^\/api\/attendance\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attendance` },
|
||||
{ method: 'GET', v1: /^\/api\/allocations\/my-groups$/, v2: () => '/api/v2/me/allocation-groups' },
|
||||
{ method: 'GET', v1: /^\/api\/allocations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/allocations` },
|
||||
{ method: 'POST', v1: /^\/api\/allocations\/sets$/, v2: () => '/api/v2/allocation-sets' },
|
||||
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)$/, v2: m => `/api/v2/allocation-sets/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/allocations\/sets\/([^/]+)\/lists$/, v2: m => `/api/v2/allocation-sets/${m[1]}/lists` },
|
||||
{ method: 'POST', v1: /^\/api\/allocations\/sets\/([^/]+)\/(groups|auto)$/, v2: m => `/api/v2/allocation-sets/${m[1]}/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
|
||||
{ method: 'DELETE', v1: /^\/api\/allocations\/sets\/([^/]+)\/assignments$/, v2: m => `/api/v2/allocation-sets/${m[1]}/assignments` },
|
||||
{ method: 'PUT', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/allocations\/groups\/([^/]+)$/, v2: m => `/api/v2/allocation-groups/${m[1]}` },
|
||||
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
|
||||
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
|
||||
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
|
||||
// 1.51.10: managing registrations
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
|
||||
// 1.51.9: series, season passes, bundles, coupons, campuses
|
||||
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
{ method: 'GET', v1: /^\/api\/series\/by-slug\/([^/]+)$/, v2: m => `/api/v2/series/by-slug/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/series\/([^/]+)\/compare$/, v2: m => `/api/v2/series/${m[1]}/compare` },
|
||||
{ method: 'POST', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
|
||||
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
|
||||
{ method: 'PUT', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/season-passes\/mine$/, v2: () => '/api/v2/me/season-passes' },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/purchases\/([^/]+)\/checkout$/, v2: m => `/api/v2/season-pass-purchases/${m[1]}/checkout` },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/([^/]+)\/buy$/, v2: m => `/api/v2/season-passes/${m[1]}/buy` },
|
||||
{ method: 'GET', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
|
||||
{ method: 'PUT', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/bundles\/quote$/, v2: () => '/api/v2/bundles/quote' },
|
||||
{ method: 'GET', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
|
||||
{ method: 'POST', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
|
||||
{ method: 'PUT', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/coupons\/validate$/, v2: () => '/api/v2/coupons/check' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/redemptions$/, v2: () => '/api/v2/coupons/redemptions' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
|
||||
{ method: 'POST', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/admin$/, v2: () => '/api/v2/campuses/admin' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/by-slug\/([^/]+)$/, v2: m => `/api/v2/campuses/by-slug/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/settings$/, v2: () => '/api/v2/campuses/enabled' },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/order$/, v2: () => '/api/v2/campuses/order' },
|
||||
{ method: 'POST', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/([^/]+)\/audience$/, v2: m => `/api/v2/campuses/${m[1]}/audience` },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export type ErrorCode =
|
||||
| 'CONFLICT'
|
||||
| 'RATE_LIMITED'
|
||||
| 'READ_ONLY'
|
||||
| 'UNAVAILABLE'
|
||||
| 'INTERNAL';
|
||||
|
||||
export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
@@ -24,6 +25,8 @@ export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
CONFLICT: 409,
|
||||
RATE_LIMITED: 429,
|
||||
READ_ONLY: 403,
|
||||
// Something this depends on (the Hub, the AI service) can't be reached right now.
|
||||
UNAVAILABLE: 503,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
@@ -31,6 +34,8 @@ export class ApiError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly status: number;
|
||||
readonly fields?: Record<string, string>;
|
||||
/** A more specific cause, e.g. CAMPUS_HAS_EVENTS or TWO_FACTOR_REQUIRED. */
|
||||
reason?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
|
||||
super(message);
|
||||
@@ -52,7 +57,7 @@ export function zodFields(err: ZodError): Record<string, string> {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
|
||||
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED', 503: 'UNAVAILABLE' };
|
||||
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
|
||||
|
||||
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
|
||||
@@ -69,14 +74,19 @@ export function toApiError(err: unknown, responseStatus: number): ApiError {
|
||||
// utils throw errors with statusCode. Those messages are written for people already.
|
||||
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
|
||||
const code = STATUS_CODE[status];
|
||||
if (code) return new ApiError(code, e.message || 'Request failed.');
|
||||
if (code) {
|
||||
const out = new ApiError(code, e.message || 'Request failed.');
|
||||
// A service's own code (not a Prisma one) says more precisely what happened.
|
||||
if (typeof e.code === 'string' && e.code && !/^P\d{4}$/.test(e.code)) out.reason = e.code;
|
||||
return out;
|
||||
}
|
||||
return new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const apiErr = toApiError(err, res.statusCode);
|
||||
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
|
||||
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } });
|
||||
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}), ...(apiErr.reason ? { reason: apiErr.reason } : {}) } });
|
||||
};
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
|
||||
|
||||
+10
-1
@@ -9,11 +9,20 @@ import auth from '../middleware/authMiddleware';
|
||||
import './resources/eventsManage';
|
||||
import './resources/eventDetails';
|
||||
import './resources/setup';
|
||||
import './resources/seriesDeals';
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
import './resources/registrationsManage';
|
||||
import './resources/money';
|
||||
import './resources/attendance';
|
||||
import './resources/corrections';
|
||||
import './resources/kidsVolunteers';
|
||||
import './resources/peopleMessaging';
|
||||
import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
import './resources/ai';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
@@ -84,7 +93,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
sessionStorage.setItem('v2docs_token', t);
|
||||
if (params.has('token')) history.replaceState(null, '', location.pathname);
|
||||
document.getElementById('login').hidden = true;
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
|
||||
@@ -0,0 +1,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 });
|
||||
@@ -0,0 +1,189 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { id } from '../schemas';
|
||||
import { notFound } from '../errors';
|
||||
import { prisma } from '../legacy';
|
||||
import { aiStatus, getAiSettings, saveAiSettings, usageThisMonth, HELPERS, clearAiStatusCache } from '../../ai';
|
||||
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText, REPORTS_WITHOUT_PEOPLE, type FeedbackQuestion } from '../../ai/helpers';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { campusIndependent, scopeBody } = require('../../utils/campusScope') as { campusIndependent(reason: string): any; scopeBody(targets: [string, string][], opts?: object): any };
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; eventId?: string; records?: number }): Promise<void> };
|
||||
const feedbackUtils = require('../../utils/feedback') as { summarise(fields: unknown[], answers: { fieldId: string; value: string }[]): any[] };
|
||||
|
||||
const helperFlags = z.object(Object.fromEntries(HELPERS.map(h => [h, z.boolean()])) as Record<(typeof HELPERS)[number], z.ZodBoolean>);
|
||||
|
||||
const status = z.object({
|
||||
enabled: z.boolean().describe('false: hide the AI buttons (the licence has no AI, or the site isn\'t connected to the Hub)'),
|
||||
capReached: z.boolean().describe('true: show the buttons disabled until `renewsOn`'),
|
||||
available: z.boolean().describe('false: the Hub couldn\'t be reached just now'),
|
||||
capCents: z.number().describe('The monthly allowance, in cents'),
|
||||
usedCents: z.number().describe('Used this month, in cents (fractions of a cent)'),
|
||||
usedPercent: z.number(),
|
||||
renewsOn: z.string().nullable().describe('YYYY-MM-DD'),
|
||||
helpers: helperFlags.describe('Each helper switched on or off in Site settings → AI'),
|
||||
});
|
||||
|
||||
const settings = z.object({
|
||||
helpers: helperFlags,
|
||||
dailyLimitPerPerson: z.number().int().min(0).max(1000).describe('0 = no limit'),
|
||||
});
|
||||
|
||||
const use = z.object({ uses: z.number().int(), chargedCents: z.number() });
|
||||
|
||||
export const getAiStatus = defineRoute({
|
||||
method: 'get',
|
||||
path: '/ai/status',
|
||||
tag: 'AI',
|
||||
summary: 'Whether to show the AI buttons',
|
||||
description: 'Hidden when `enabled` is false; shown but disabled when `capReached` (until `renewsOn`, or until the allowance is raised). Cached for a minute; every AI call refreshes it.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'campusIndependent (the site\'s AI allowance)',
|
||||
middleware: [campusIndependent('the site\'s AI allowance')],
|
||||
response: single(status),
|
||||
async handler() {
|
||||
return { data: await aiStatus() };
|
||||
},
|
||||
});
|
||||
|
||||
export const getAiUsage = defineRoute({
|
||||
method: 'get',
|
||||
path: '/ai/usage',
|
||||
tag: 'AI',
|
||||
summary: 'AI use this month',
|
||||
description: 'For Site settings → AI: the allowance, this site\'s switches, and this month\'s use per person, per helper and per day.',
|
||||
access: 'admin',
|
||||
response: single(z.object({
|
||||
status,
|
||||
settings,
|
||||
people: z.array(use.extend({ userId: z.string().nullable(), name: z.string() })),
|
||||
helpers: z.array(use.extend({ helper: z.string() })),
|
||||
days: z.array(use.extend({ day: z.string() })),
|
||||
})),
|
||||
async handler() {
|
||||
clearAiStatusCache();
|
||||
const [s, settingsNow, usage] = await Promise.all([aiStatus({ fresh: true }), getAiSettings(), usageThisMonth()]);
|
||||
return { data: { status: s, settings: settingsNow, ...usage } };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAiSettings = defineRoute({
|
||||
method: 'put',
|
||||
path: '/ai/settings',
|
||||
tag: 'AI',
|
||||
summary: 'Switch helpers on or off, and set a daily limit',
|
||||
access: 'admin',
|
||||
body: z.object({ helpers: helperFlags.partial().optional(), dailyLimitPerPerson: z.number().int().min(0).max(1000).optional() }),
|
||||
response: single(settings),
|
||||
async handler({ body, user, req }) {
|
||||
return { data: await saveAiSettings(body, user!, getClientIp(req)) };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── The helpers ──────────────────────────────────────────────────────────────
|
||||
// Each answer is a suggestion for a person to check: nothing is saved or sent by these routes.
|
||||
|
||||
const withStatus = <T extends z.ZodType>(output: T) => single(z.object({ output, status }));
|
||||
const HELPER_SCOPE = 'campusIndependent (an AI suggestion from what is sent; nothing is read or changed)';
|
||||
const helperAccess = { access: 'supervisor' as const, campusScope: HELPER_SCOPE, middleware: [campusIndependent('an AI suggestion from what is sent; nothing is read or changed')] };
|
||||
const AI_DESCRIPTION = 'Goes through the Cross Events Hub, which counts it against the site\'s monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.';
|
||||
|
||||
const textList = z.array(z.string());
|
||||
const eventDraft = z.object({
|
||||
title: z.string(), summary: z.string(), description: z.string(),
|
||||
startDate: z.string(), startTime: z.string(), endDate: z.string(), endTime: z.string(),
|
||||
location: z.string(), capacity: z.number(), needsParentalConsent: z.boolean(), ageGroup: z.string(),
|
||||
ticketOptions: z.array(z.object({ name: z.string(), priceRand: z.number(), description: z.string(), earlyBirdPriceRand: z.number(), earlyBirdUntil: z.string() })),
|
||||
sessions: z.array(z.object({ date: z.string(), startTime: z.string(), endTime: z.string(), label: z.string() })),
|
||||
costs: z.array(z.object({ item: z.string(), basis: z.string(), amountRand: z.number() })),
|
||||
checks: textList,
|
||||
});
|
||||
|
||||
export const aiDraftEvent = defineRoute({
|
||||
method: 'post', path: '/ai/draft-event', tag: 'AI', summary: 'Draft an event from a description',
|
||||
description: `Fills in the event form from the organiser's notes. \`warnings\` are checked in code (a weekday that doesn't match its date, costs that a full event can't cover); \`draft.checks\` are the AI's own. ${AI_DESCRIPTION}`,
|
||||
...helperAccess,
|
||||
body: z.object({ notes: z.string().trim().min(10).max(4000) }),
|
||||
response: withStatus(z.object({ draft: eventDraft, warnings: textList, totalCostRand: z.number(), breakEvenPlaces: z.number().nullable() })),
|
||||
async handler({ body, user }) {
|
||||
const { status: s, ...output } = await draftEvent(user!.id, body.notes);
|
||||
return { data: { output, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiSummariseReport = defineRoute({
|
||||
method: 'post', path: '/ai/summarise-report', tag: 'AI', summary: 'Summarise a report',
|
||||
description: `A few plain-language insights on a report as it is on screen. Only reports without people in them: ${REPORTS_WITHOUT_PEOPLE.join(', ')}. ${AI_DESCRIPTION}`,
|
||||
...helperAccess,
|
||||
body: z.object({
|
||||
reportKey: z.enum(REPORTS_WITHOUT_PEOPLE),
|
||||
title: z.string().max(300), subtitle: z.string().max(500).optional(),
|
||||
stats: z.array(z.object({ label: z.string().max(200), value: z.union([z.string().max(200), z.number()]) })).max(40).optional(),
|
||||
columns: z.array(z.string().max(200)).max(40),
|
||||
rows: z.array(z.array(z.union([z.string().max(500), z.number(), z.null()]).transform(v => v ?? ''))).max(5000),
|
||||
}),
|
||||
response: withStatus(z.object({ headline: z.string(), insights: z.array(z.object({ title: z.string(), detail: z.string() })), watchOut: textList })),
|
||||
async handler({ body, user }) {
|
||||
const { summary, status: s } = await summariseReport(user!.id, body);
|
||||
return { data: { output: summary, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiDraftMessage = defineRoute({
|
||||
method: 'post', path: '/ai/draft-message', tag: 'AI', summary: 'Draft an email or WhatsApp message',
|
||||
description: `Turns a short note into a message, using {{name}} and the event placeholders. With \`eventId\`, the event's title, start and place are given to the AI as facts. ${AI_DESCRIPTION}`,
|
||||
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']), note: z.string().trim().min(3).max(3000), eventId: id.optional() }),
|
||||
response: withStatus(z.object({ subject: z.string(), body: z.string() })),
|
||||
async handler({ body, user }) {
|
||||
let event = null;
|
||||
if (body.eventId) {
|
||||
const e = await prisma.event.findUnique({ where: { id: body.eventId }, select: { title: true, startDate: true, location: true } });
|
||||
if (!e) throw notFound('The event');
|
||||
event = { title: e.title, start: e.startDate.toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }), location: e.location };
|
||||
}
|
||||
const { message, status: s } = await draftMessage(user!.id, { channel: body.channel, note: body.note, event });
|
||||
return { data: { output: message, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiSummariseFeedback = defineRoute({
|
||||
method: 'post', path: '/ai/summarise-feedback', tag: 'AI', summary: 'Summarise an event\'s feedback',
|
||||
description: `Themes, what people liked, what to improve and suggestions, from the event's feedback. Sent without who answered; contact details in comments are kept back. Recorded in the access log. ${AI_DESCRIPTION}`,
|
||||
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
|
||||
body: z.object({ eventId: id }),
|
||||
response: withStatus(z.object({
|
||||
overall: z.string(), liked: z.array(z.object({ theme: z.string(), mentions: z.number() })), improve: z.array(z.object({ theme: z.string(), mentions: z.number() })),
|
||||
quotes: textList, nextTime: textList, responses: z.number(),
|
||||
})),
|
||||
async handler({ body, user, req }) {
|
||||
const form = await prisma.feedbackForm.findUnique({
|
||||
where: { eventId: body.eventId },
|
||||
include: { event: { select: { title: true } }, fields: { orderBy: { order: 'asc' } }, responses: { include: { answers: true } }, _count: { select: { invites: true } } },
|
||||
});
|
||||
if (!form) throw notFound('Feedback for this event');
|
||||
if (!form.responses.length) throw Object.assign(notFound('Feedback answers for this event'), { reason: 'AI_NOTHING_TO_SUMMARISE' });
|
||||
// Never who answered: only the answers.
|
||||
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value })));
|
||||
const questions: FeedbackQuestion[] = feedbackUtils.summarise(form.fields, answers).map((q: any) => ({
|
||||
label: q.label, type: q.type, answered: q.answered, average: q.average, counts: q.counts, texts: q.texts?.map((t: { value: string }) => t.value),
|
||||
}));
|
||||
await logDataAccess(req, { action: 'ai_feedback', eventId: body.eventId, records: form.responses.length });
|
||||
const { summary, status: s } = await summariseFeedback(user!.id, { event: form.event, responses: form.responses.length, invited: form._count.invites, questions });
|
||||
return { data: { output: { ...summary, responses: form.responses.length }, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiImproveText = defineRoute({
|
||||
method: 'post', path: '/ai/improve-text', tag: 'AI', summary: 'Improve a piece of text',
|
||||
description: `Fixes spelling and grammar and makes it clearer (\`improve\`), shorter (\`shorter\`) or only fixes mistakes (\`fix\`), keeping every fact. ${AI_DESCRIPTION}`,
|
||||
...helperAccess,
|
||||
body: z.object({ text: z.string().trim().min(3).max(8000), mode: z.enum(['improve', 'shorter', 'fix']).default('improve'), where: z.enum(['description', 'banner', 'message']).default('description') }),
|
||||
response: withStatus(z.object({ text: z.string(), changes: textList })),
|
||||
async handler({ body, user }) {
|
||||
const { result, status: s } = await improveText(user!.id, body);
|
||||
return { data: { output: result, status: s } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tickets and attendance on v2 (1.51.12): tickets (mine, one, sending, transfers, the staff
|
||||
* lists, generating), scan locks (offline scanning), the attendance register and certificates,
|
||||
* and allocations (groups, rooms, teams). Each route runs the v1 handler (viaV1.ts) behind the
|
||||
* same access and campus checks as v1. Scanning itself is /checkin (1.51.2).
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
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; ownerOk?: boolean; nameCampus?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
campusIndependent(reason: string): any;
|
||||
};
|
||||
const t = require('../../controllers/ticketController');
|
||||
const locks = require('../../controllers/scanLockController');
|
||||
const att = require('../../controllers/attendanceController');
|
||||
const alloc = require('../../controllers/allocationController');
|
||||
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
const loose = z.looseObject({});
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
|
||||
// ─── 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', 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() }), 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'), 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'), 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, 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 }), 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, 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'), 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) }), 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, 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, 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, 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, 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, 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, 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, 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 }), 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 }), 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() }), 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, 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'), 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, 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, 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() }), 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) }), 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', 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' }, 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',
|
||||
description: 'Its owner, or staff.', access: 'user', params: idParams, file: { contentType: 'application/pdf' }, v1: att.downloadCertificate,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, 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, 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',
|
||||
description: '`format=pdf` or `xlsx`.', access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('format', 'sessionId'),
|
||||
file: { contentType: 'application/pdf' }, map: asEvent, v1: att.exportRegister,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, 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, 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, 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',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, file: { contentType: 'application/pdf' }, map: asEvent, v1: att.downloadEventCertificates,
|
||||
});
|
||||
|
||||
// ─── Allocations (groups, rooms, teams) ──────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
|
||||
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], 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, 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 }), 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, 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
|
||||
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
|
||||
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { listData, v1Route } from '../viaV1';
|
||||
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 cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
unscopedOnly: any;
|
||||
};
|
||||
const pay = require('../../controllers/paymentController');
|
||||
const providers = require('../../controllers/paymentProvidersController');
|
||||
const cashup = require('../../controllers/cashupController');
|
||||
const ptx = require('../../controllers/providerTransactionsController');
|
||||
const waitlist = require('../../controllers/waitlistController');
|
||||
const sponsor = require('../../controllers/sponsorshipController');
|
||||
const consent = require('../../controllers/consentController');
|
||||
const forms = require('../../controllers/formController');
|
||||
const feedback = require('../../controllers/feedbackController');
|
||||
|
||||
/** Optional text filters, passed to v1 as they come. */
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
// Links in messages are credentials, so guessing stays slow (as on v1).
|
||||
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
|
||||
// ─── 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', 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', 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({}), 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], 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({}), 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, 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, 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',
|
||||
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
|
||||
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() }), 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 }), 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 }), 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()]) }), 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, 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, 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() }), 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, 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\'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 } }), 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 } }), 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, 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, 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() }), 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'), 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, 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, 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 } }), 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, 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, 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, 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 } }), 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'), 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], 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({}), 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({}), documented: { response: txAnswer(providerTransaction) }, v1: ptx.ignoreProviderTransaction,
|
||||
});
|
||||
|
||||
// ─── Waitlist ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
|
||||
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), 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', 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, 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, 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, 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, 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, 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 }), 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, 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, 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, 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 }), 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({}), 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, 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({}), 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 ───────────────────────────────────────────────────
|
||||
|
||||
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'), 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'), 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, 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({}), 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, 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, 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, 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, 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 });
|
||||
@@ -0,0 +1,225 @@
|
||||
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';
|
||||
import { id, idParams } from '../schemas';
|
||||
import * as admin from '../../services/registrationAdmin';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- registrations are passed through with their includes */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
};
|
||||
const { notOnSeasonPass } = require('../../utils/seasonPass') as { notOnSeasonPass: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** The v2 request as the registration service's Call. */
|
||||
function callFrom(req: Request, user: ApiUser | null, parts: { params?: object; body?: object; query?: object }): admin.Call {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return {
|
||||
actor: user ? { id: user.id, role: user.role, email: user.email } : null,
|
||||
params: (parts.params ?? {}) as Record<string, string>,
|
||||
body: parts.body ?? {},
|
||||
query: parts.query ?? {},
|
||||
readOnly: !!(req as any).readOnly,
|
||||
ip: getClientIp(req),
|
||||
listWhere: () => campusScope.listWhere(req, 'event'),
|
||||
};
|
||||
}
|
||||
|
||||
const OWNER_OR_STAFF = 'scopeEvent(registration, owner-ok)';
|
||||
const ownerOrStaff = campusScope.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration with everything the dashboards show: items and their prices, payments, attendees, coupons, `totalDueComputed`. */
|
||||
const fullRegistration = z.looseObject({ id, status: z.enum(STATUSES), eventId: id, userId: id, totalDueComputed: z.number().optional() });
|
||||
const message = single(z.looseObject({ message: z.string() }));
|
||||
const lineBody = z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() });
|
||||
const attendeeBody = z.looseObject({ id: id.optional().describe('An attendee already on it (left out = removed)'), eventOptionId: id, variantId: id.nullish(), name: z.string().max(200) });
|
||||
const responsesBody = z.object({
|
||||
responses: z.array(z.object({
|
||||
attendeeId: id.nullish().describe('The named attendee this form is for'),
|
||||
answers: z.record(z.string(), z.unknown()).describe('Field id → answer (stored as text)'),
|
||||
})).max(500),
|
||||
});
|
||||
|
||||
// ─── Owner (or staff) ────────────────────────────────────────────────────────
|
||||
|
||||
export const fullOne = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/full', tag: 'Registrations', summary: 'A registration with everything on it',
|
||||
description: [
|
||||
'Items and prices, payments, coupons and bundles, named attendees, form answers and the total due.',
|
||||
'The owner, a named attendee or staff — or anyone with the link when not signed in (registration ids can\'t be guessed). Attendees\' contact details are only shown to the payer and staff.',
|
||||
].join('\n\n'),
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, response: single(fullRegistration),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getRegistrationById(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const myFull = defineRoute({
|
||||
method: 'get', path: '/me/registrations/full', tag: 'Me', summary: 'My registrations with everything on them',
|
||||
description: 'What the dashboard shows: as `/registrations/{id}/full`, newest first, with the event and its form. Only upcoming ones unless `past=true`.',
|
||||
access: 'user',
|
||||
query: z.object({ past: z.enum(['true', 'false']).default('false') }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ query, user, req }) { return { data: plain(await admin.getUserRegistrations(callFrom(req, user, { query: { showPast: query.past } }))) }; },
|
||||
});
|
||||
|
||||
export const changeItems = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/items', tag: 'Registrations', summary: 'Change the tickets on a registration',
|
||||
description: [
|
||||
'The whole list of items it should have. Added places need stock and are priced now; places already paid for keep their price, and nothing goes below tickets already issued or the amount paid.',
|
||||
'Not for registrations made with a season pass, nor once the event has ended or is closed. Staff can add kids and volunteer places; owners can only keep or lower them.',
|
||||
].join('\n\n'),
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ options: z.array(lineBody.omit({ sessionIds: true })).min(1).max(100) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationOptions(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendInvoice = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/invoice', tag: 'Registrations', summary: 'Send me the invoice again',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']).default('email') }), response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.sendInvoice(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
export const applyCoupon = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Apply a coupon code',
|
||||
description: 'By the owner, or by staff on their behalf (logged). Not on season pass registrations.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ code: z.string().trim().min(1).max(40) }), response: single(fullRegistration.extend({ message: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.applyCoupon(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const removeCoupon = defineRoute({
|
||||
method: 'delete', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Take a coupon code off',
|
||||
description: 'Staff. Its use is freed. Not once a ticket on it has been scanned; unused tickets are withdrawn if it\'s no longer paid.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.removeCoupon(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const setAttendees = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/attendees', tag: 'Registrations', summary: 'Name the people on a registration',
|
||||
description: 'The whole list: attendees left out are removed (not once their ticket has been scanned). `ticketDelivery` says who gets the tickets.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ attendees: z.array(attendeeBody).max(1000), ticketDelivery: z.enum(['payer', 'each']).optional() }),
|
||||
response: single(z.looseObject({ id, ticketDelivery: z.string().nullable(), attendees: z.array(z.looseObject({ id, name: z.string() })) })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateAttendees(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendFormLink = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/attendees/:attendeeId/form-link', tag: 'Registrations', summary: 'Send an attendee their form',
|
||||
description: 'A link to fill in their own attendee form, by email and/or WhatsApp.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: z.object({ id, attendeeId: id }), response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.sendAttendeeFormLink(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const getDraft = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Attendee forms saved part-way',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
response: single(z.object({ data: z.record(z.string(), z.unknown()), updatedAt: z.string().nullable() })),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getFormDraft(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const saveDraft = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Save attendee forms part-way',
|
||||
description: 'Kept for the registration\'s owner (also when staff save it), until the forms are submitted.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ data: z.record(z.string(), z.unknown()) }), response: single(z.object({ message: z.string(), updatedAt: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.saveFormDraft(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const submitForms = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Submit attendee forms',
|
||||
description: 'One response per main ticket at most; each named attendee answers once. Tickets are issued once it\'s paid and the forms are in.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, body: responsesBody,
|
||||
response: single(z.looseObject({ message: z.string(), count: z.number().int().optional() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.submitFormResponses(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const replaceForms = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Replace the attendee forms',
|
||||
description: 'Supervisors: every response on the registration is replaced by these.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, body: responsesBody, response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.replaceFormResponses(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
// ─── Staff ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const list = defineRoute({
|
||||
method: 'get', path: '/registrations', tag: 'Registrations', summary: 'Every registration (staff)',
|
||||
description: 'Newest first, for the events you may see. `unpaid=true` for ones still owing; `userId` for one person\'s.',
|
||||
access: 'staff', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100).describe('At most 200'),
|
||||
unpaid: z.enum(['true', 'false']).default('false'),
|
||||
userId: id.optional(),
|
||||
}),
|
||||
response: z.object({ data: z.array(fullRegistration), meta: z.object({ page: z.number().int(), limit: z.number().int(), total: z.number().int(), pages: z.number().int() }) }),
|
||||
async handler({ query, user, req }) {
|
||||
const out = await admin.getRegistrations(callFrom(req, user, { query: { page: String(query.page), limit: String(query.limit), unpaid: query.unpaid === 'true' ? '1' : '', userId: query.userId } }));
|
||||
return pageOf(plain(out.data), out.total, { page: out.page, limit: out.limit });
|
||||
},
|
||||
});
|
||||
|
||||
export const forEvent = defineRoute({
|
||||
method: 'get', path: '/events/:id/registrations', tag: 'Registrations', summary: 'An event\'s registrations',
|
||||
description: 'Oldest first, with tickets, payments and attendees. `search` matches the name, email or phone.',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
query: z.object({ search: z.string().max(200).optional() }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ params, query, user, req }) { return { data: plain(await admin.getRegistrationsByEvent(callFrom(req, user, { params: { eventId: params.id }, query }))) }; },
|
||||
});
|
||||
|
||||
export const setStatus = defineRoute({
|
||||
method: 'patch', path: '/registrations/:id/status', tag: 'Registrations', summary: 'Set a registration\'s status',
|
||||
description: 'Staff. `paid` issues and sends the tickets; leaving `paid` withdraws unused tickets (refused once one has been scanned); `cancelled` frees its places for the waitlist.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.object({ status: z.enum(STATUSES) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationStatus(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const manual = defineRoute({
|
||||
method: 'post', path: '/registrations/manual', tag: 'Registrations', summary: 'Register someone (at the door, by phone)',
|
||||
description: [
|
||||
'Supervisors. Finds the person by email or phone (refused when those are two different accounts) or makes an account and sends them an activation link (`skipActivationNotice` to not).',
|
||||
'Added to their existing registration for the event if they have one. Free ones are marked paid and get their tickets; otherwise they\'re sent a link to pay.',
|
||||
].join('\n\n'),
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })], status: 201,
|
||||
body: z.looseObject({
|
||||
eventId: id,
|
||||
options: z.array(lineBody).min(1).max(100),
|
||||
user: z.looseObject({ name: z.string().trim().min(1).max(200), email: z.string().max(200).nullish(), phoneNumber: z.string().max(40).nullish(), password: z.string().max(200).optional().describe('Self-service kiosk: the visitor sets it, and the account is active at once') }),
|
||||
notificationPreference: z.string().max(20).nullish().describe('email, whatsapp or both (anything else: worked out from the contact details given)'),
|
||||
skipActivationNotice: z.boolean().optional(),
|
||||
couponCode: z.string().max(40).nullish(),
|
||||
attendees: z.array(attendeeBody).max(1000).optional(),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional(),
|
||||
}),
|
||||
response: single(fullRegistration.extend({ couponError: z.string().optional() })),
|
||||
async handler({ body, user, req }) { return { data: plain(await admin.createManualRegistration(callFrom(req, user, { body }))) }; },
|
||||
});
|
||||
|
||||
export const bulk = defineRoute({
|
||||
method: 'post', path: '/registrations/bulk', tag: 'Registrations', summary: 'Do one thing to many registrations',
|
||||
description: '`markPaid`, `cancel`, `moveOption`, `allocate` or `message`, for up to 500 you may manage. Each is done on its own: the answer lists what worked and what didn\'t.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
|
||||
response: single(z.looseObject({})),
|
||||
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)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { z } from 'zod';
|
||||
import * as D from '../docs';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import * as series from '../../services/series';
|
||||
import * as deals from '../../services/deals';
|
||||
import * as campuses from '../../services/campuses';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<any>;
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
const ctxOf = async (req: Request) => {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return { scope: await campusScope.scopeFor(req), ip: getClientIp(req) };
|
||||
};
|
||||
const actorOf = (user: ApiUser | null) => ({ id: user!.id, role: user!.role, email: user!.email });
|
||||
const deleted = single(z.object({ id, deleted: z.literal(true) }));
|
||||
const SPANS = 'campusIndependent (a series spans campuses; events are checked when they join it)';
|
||||
|
||||
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const seriesRecord = z.looseObject({ id, name: z.string(), slug: z.string(), description: z.string().nullable(), picture: z.string().nullable() });
|
||||
const seriesBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().max(80).optional().describe('The web address part (/series/<slug>); made from the name when left out'),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
picture: z.string().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listSeries = defineRoute({
|
||||
method: 'get', path: '/series', tag: 'Series', summary: 'Every series',
|
||||
description: 'With its events (the ones you may see — a series spans campuses), followers and event count.',
|
||||
access: 'supervisor', campusScope: 'filters each series\' events by campus (listSeries)',
|
||||
response: z.object({ data: z.array(seriesRecord.extend({ followers: z.number().int(), eventCount: z.number().int(), events: z.array(z.looseObject({ id, title: z.string(), startDate: isoDate })) })) }),
|
||||
async handler({ req }) { return { data: plain(await series.listSeries(await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const createSeries = defineRoute({
|
||||
method: 'post', path: '/series', tag: 'Series', summary: 'Create a series',
|
||||
access: 'supervisor', campusScope: SPANS, status: 201, body: seriesBody, response: single(seriesRecord),
|
||||
async handler({ body, user, req }) { return { data: plain(await series.createSeries(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateSeries = defineRoute({
|
||||
method: 'patch', path: '/series/:id', tag: 'Series', summary: 'Change a series',
|
||||
access: 'supervisor', campusScope: SPANS, params: idParams, body: seriesBody.partial(), response: single(seriesRecord),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await series.updateSeries(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteSeries = defineRoute({
|
||||
method: 'delete', path: '/series/:id', tag: 'Series', summary: 'Delete a series',
|
||||
description: 'Admins. Its events stay (without a series) and followers are dropped. Not once season passes have been sold.',
|
||||
access: 'admin', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await series.deleteSeries(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const seriesPage = defineRoute({
|
||||
method: 'get', path: '/series/by-slug/:slug', tag: 'Series', summary: 'A series\' public page',
|
||||
description: 'Upcoming and past public events, season passes on sale, and whether you follow it.',
|
||||
access: 'optional', params: z.object({ slug: z.string().min(1).max(80) }),
|
||||
response: single(seriesRecord.extend({ upcoming: z.array(z.looseObject({ id })), past: z.array(z.looseObject({ id })), following: z.boolean(), passes: z.array(z.looseObject({ id, name: z.string(), price: z.number() })) })),
|
||||
async handler({ params, user, req }) {
|
||||
const page = plain(await series.seriesPage(params.slug, user?.id ?? null));
|
||||
return { data: { ...page, picture: absoluteUrl(req, page.picture) } };
|
||||
},
|
||||
});
|
||||
|
||||
export const compareSeries = defineRoute({
|
||||
method: 'get', path: '/series/:id/compare', tag: 'Series', summary: 'Year on year',
|
||||
description: 'Registrations, tickets, income and attendance for each event in the series that you may see, oldest first.',
|
||||
access: 'supervisor', campusScope: 'scopeList (eventWhere in compareSeries)', params: idParams, response: single(z.looseObject({})),
|
||||
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))) }; },
|
||||
});
|
||||
|
||||
export const follow = defineRoute({
|
||||
method: 'put', path: '/series/:id/follow', tag: 'Series', summary: 'Follow a series',
|
||||
description: 'Hear about new events in it (News & announcements).',
|
||||
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
|
||||
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, true) }; },
|
||||
});
|
||||
|
||||
export const unfollow = defineRoute({
|
||||
method: 'delete', path: '/series/:id/follow', tag: 'Series', summary: 'Stop following a series',
|
||||
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
|
||||
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, false) }; },
|
||||
});
|
||||
|
||||
// ─── Season passes ───────────────────────────────────────────────────────────
|
||||
|
||||
const pass = z.looseObject({ id, seriesId: id, name: z.string(), price: z.number(), isActive: z.boolean(), items: z.array(z.looseObject({ eventId: id })) });
|
||||
const passBody = z.looseObject({
|
||||
seriesId: id,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
price: z.union([z.number().min(0), z.string().max(20)]),
|
||||
saleEndsAt: z.string().max(40).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
items: z.array(z.object({ eventId: id, eventOptionId: id, variantId: id.nullable().optional() })).min(1).max(100),
|
||||
});
|
||||
|
||||
export const listPasses = defineRoute({
|
||||
method: 'get', path: '/season-passes', tag: 'Series', summary: 'Season passes',
|
||||
description: 'With their events, how the price is shared between them, and how many were sold or are unpaid.',
|
||||
access: 'supervisor', campusScope: SPANS, query: z.object({ seriesId: id.optional() }),
|
||||
response: z.object({ data: z.array(pass.extend({ sold: z.number().int(), unpaid: z.number().int() })) }),
|
||||
async handler({ query }) { return { data: plain(await series.listPasses(query.seriesId)) }; },
|
||||
});
|
||||
|
||||
export const createPass = defineRoute({
|
||||
method: 'post', path: '/season-passes', tag: 'Series', summary: 'Create a season pass',
|
||||
description: 'One price for several events in a series. Campus-scoped supervisors can only include their own campuses\' events.',
|
||||
access: 'supervisor', campusScope: 'checked in createPass (its events\' campuses)', status: 201, body: passBody, response: single(pass),
|
||||
async handler({ body, user, req }) { return { data: plain(await series.createPass(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updatePass = defineRoute({
|
||||
method: 'patch', path: '/season-passes/:id', tag: 'Series', summary: 'Change a season pass',
|
||||
description: 'Its events and price can\'t change once somebody has bought it (switch it off and make a new one).',
|
||||
access: 'supervisor', campusScope: 'checked in updatePass (its events\' campuses)', params: idParams, body: passBody.partial(), response: single(pass),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await series.updatePass(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deletePass = defineRoute({
|
||||
method: 'delete', path: '/season-passes/:id', tag: 'Series', summary: 'Delete a season pass',
|
||||
description: 'Only one nobody has bought — otherwise switch it off.',
|
||||
access: 'supervisor', campusScope: 'checked in deletePass (its events\' campuses)', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await series.deletePass(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const buyPass = defineRoute({
|
||||
method: 'post', path: '/season-passes/:id/buy', tag: 'Series', summary: 'Buy a season pass',
|
||||
description: 'Registers you at every event on it, then answers with a checkout to pay (or issues the tickets when it\'s free).',
|
||||
access: 'user', status: 201, params: idParams,
|
||||
body: z.object({ quantity: z.number().int().min(1).max(50).default(1), provider: z.string().max(40).optional() }),
|
||||
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)) }; },
|
||||
});
|
||||
|
||||
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)) }; },
|
||||
});
|
||||
|
||||
export const myPasses = defineRoute({
|
||||
method: 'get', path: '/me/season-passes', tag: 'Me', summary: 'My season passes',
|
||||
access: 'user',
|
||||
response: z.object({ data: z.array(z.looseObject({ id, quantity: z.number().int(), total: z.number(), status: z.string(), createdAt: isoDate })) }),
|
||||
async handler({ user }) { return { data: plain(await series.myPassPurchases(user!.id)) }; },
|
||||
});
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const basketBody = z.object({
|
||||
eventId: id,
|
||||
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() })).max(50),
|
||||
forUserId: id.nullable().optional().describe('Staff: price it for this person (null = a walk-in without an account)'),
|
||||
});
|
||||
const bundle = z.looseObject({ id, eventId: id, name: z.string(), kind: z.enum(['exact', 'mix', 'free', 'group']), price: z.number().nullable(), isActive: z.boolean(), summary: z.string(), headline: z.string(), available: z.boolean() });
|
||||
const bundleBody = z.looseObject({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
kind: z.enum(['exact', 'mix', 'free', 'group']).optional().describe('exact = this set; mix = any N; free = buy N get M free; group = a price for groups'),
|
||||
price: z.union([z.number(), z.string().max(20)]).nullable().optional(),
|
||||
setSize: z.number().int().nullable().optional(),
|
||||
freeCount: z.number().int().nullable().optional(),
|
||||
groupPricing: z.enum(['per_ticket', 'percent']).nullable().optional(),
|
||||
items: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).optional() })).max(50).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
validFrom: z.string().max(40).nullable().optional(),
|
||||
validUntil: z.string().max(40).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listBundles = defineRoute({
|
||||
method: 'get', path: '/events/:id/bundles', tag: 'Deals', summary: 'An event\'s bundle deals',
|
||||
description: 'With how often each has been used and the discount given.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
response: z.object({ data: z.array(bundle.extend({ uses: z.number(), registrations: z.number().int(), totalDiscount: z.number() })) }),
|
||||
async handler({ params }) { return { data: plain(await deals.listBundles(params.id)) }; },
|
||||
});
|
||||
|
||||
export const createBundle = defineRoute({
|
||||
method: 'post', path: '/events/:id/bundles', tag: 'Deals', summary: 'Add a bundle deal',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [campusScope.scopeEvent({ need: 'manage' })], status: 201,
|
||||
params: idParams, body: bundleBody, response: single(bundle),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.createBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateBundle = defineRoute({
|
||||
method: 'patch', path: '/bundles/:id', tag: 'Deals', summary: 'Change a bundle deal',
|
||||
description: 'Only what\'s sent changes; when a pricing field is sent the whole deal is checked again.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
|
||||
params: idParams, body: bundleBody.partial(), response: single(bundle),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.updateBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteBundle = defineRoute({
|
||||
method: 'delete', path: '/bundles/:id', tag: 'Deals', summary: 'Remove a bundle deal',
|
||||
description: 'One that\'s been used is switched off instead (`switchedOff`), so registrations that got it keep their price.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
|
||||
params: idParams, response: single(z.object({ deleted: z.literal(true).optional(), switchedOff: z.literal(true).optional(), message: z.string() })),
|
||||
async handler({ params, user, req }) { return { data: await deals.deleteBundle(params.id, actorOf(user), await ctxOf(req)) }; },
|
||||
});
|
||||
|
||||
export const quote = defineRoute({
|
||||
method: 'post', path: '/bundles/quote', tag: 'Deals', summary: 'The bundle deals a basket gets',
|
||||
description: 'Before any coupon code — what the register page shows as you choose tickets.',
|
||||
access: 'optional', body: basketBody,
|
||||
response: single(z.object({ subtotal: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), total: z.number() })),
|
||||
async handler({ body, user }) { return { data: plain(await deals.quoteBundles(body, user ? actorOf(user) : null)) }; },
|
||||
});
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const coupon = z.looseObject({ id, code: z.string(), type: z.enum(['percent', 'fixed', 'comp']), value: z.number(), active: z.boolean(), summary: z.string() });
|
||||
const couponBody = z.looseObject({
|
||||
code: z.string().trim().min(3).max(32),
|
||||
type: z.enum(['percent', 'fixed', 'comp']).describe('comp = free'),
|
||||
value: z.union([z.number(), z.string().max(20)]).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
active: z.boolean().optional(),
|
||||
stackEarlyBird: z.boolean().optional(),
|
||||
validFrom: z.string().max(40).nullable().optional(),
|
||||
validUntil: z.string().max(40).nullable().optional(),
|
||||
maxUses: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
maxUsesPerUser: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
minQuantity: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
restrictRoles: z.array(z.enum(['user', 'staff', 'supervisor', 'admin'])).optional(),
|
||||
restrictDomain: z.string().max(120).nullable().optional(),
|
||||
eventIds: z.array(id).max(200).optional().describe('Empty = every event (not for campus-scoped supervisors)'),
|
||||
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish() })).max(200).optional(),
|
||||
userIds: z.array(id).max(1000).optional(),
|
||||
});
|
||||
|
||||
export const listCoupons = defineRoute({
|
||||
method: 'get', path: '/coupons', tag: 'Deals', summary: 'Coupon codes',
|
||||
description: 'With how often each has been used and the discount given. Campus-scoped supervisors see codes for their campuses\' events.',
|
||||
access: 'supervisor', campusScope: 'scopeList (couponAllowed in listCoupons)',
|
||||
response: z.object({ data: z.array(coupon.extend({ uses: z.number().int(), totalDiscount: z.number() })) }),
|
||||
async handler({ req }) { return { data: plain(await deals.listCoupons(await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const redemptions = defineRoute({
|
||||
method: 'get', path: '/coupons/redemptions', tag: 'Deals', summary: 'Coupon and bundle uses',
|
||||
description: 'For the Discounts report, newest first.',
|
||||
access: 'supervisor', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({ eventIds: z.string().max(4000).optional().describe('Comma-separated'), from: z.string().max(40).optional(), to: z.string().max(40).optional() }),
|
||||
response: z.object({ data: z.array(z.looseObject({ id, kind: z.enum(['coupon', 'bundle']), uses: z.number(), discountAmount: z.number(), createdAt: isoDate })) }),
|
||||
async handler({ query, req }) {
|
||||
const date = (v?: string) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
|
||||
const eventIds = String(query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
return { data: plain(await deals.listRedemptions({ eventIds, from: date(query.from), to: date(query.to) }, await campusScope.listWhere(req, 'event'))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const getCoupon = defineRoute({
|
||||
method: 'get', path: '/coupons/:id', tag: 'Deals', summary: 'One coupon code with its uses',
|
||||
access: 'supervisor', campusScope: 'scopeList (couponAllowed in getCoupon)', params: idParams,
|
||||
response: single(coupon.extend({ redemptions: z.array(z.looseObject({ id })) })),
|
||||
async handler({ params, req }) { return { data: plain(await deals.getCoupon(params.id, await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const createCoupon = defineRoute({
|
||||
method: 'post', path: '/coupons', tag: 'Deals', summary: 'Create a coupon code',
|
||||
access: 'supervisor', campusScope: 'checked in createCoupon (eventIdsAllowed)', status: 201, body: couponBody, response: single(coupon),
|
||||
async handler({ body, user, req }) { return { data: plain(await deals.createCoupon(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateCoupon = defineRoute({
|
||||
method: 'patch', path: '/coupons/:id', tag: 'Deals', summary: 'Change a coupon code',
|
||||
description: 'Only what\'s sent changes. Discounts already given keep their amount; a used code can\'t be renamed.',
|
||||
access: 'supervisor', campusScope: 'checked in updateCoupon (couponAllowed)', params: idParams, body: couponBody.partial(), response: single(coupon),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.updateCoupon(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const checkCode = defineRoute({
|
||||
method: 'post', path: '/coupons/check', tag: 'Deals', summary: 'Check a coupon code against a basket',
|
||||
description: 'Always a 200: `valid` with the discount and total, or `valid: false` with why. Restricted codes need you signed in.',
|
||||
access: 'optional', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })],
|
||||
body: basketBody.extend({ code: z.string().trim().min(1).max(40) }),
|
||||
response: single(z.discriminatedUnion('valid', [
|
||||
z.object({ valid: z.literal(true), code: z.string(), type: z.string(), value: z.number(), summary: z.string(), description: z.string().nullable(), discount: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), subtotal: z.number(), total: z.number() }),
|
||||
z.object({ valid: z.literal(false), message: z.string() }),
|
||||
])),
|
||||
async handler({ body, user }) { return { data: plain(await deals.checkCode(body, user ? actorOf(user) : null)) }; },
|
||||
});
|
||||
|
||||
// ─── Campuses ────────────────────────────────────────────────────────────────
|
||||
|
||||
const publicCampus = z.looseObject({ id, name: z.string(), slug: z.string() });
|
||||
const adminCampus = publicCampus.extend({ archivedAt: isoDate.nullable(), order: z.number().int(), _count: z.looseObject({ events: z.number().int() }) });
|
||||
const campusBody = z.looseObject({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().max(80).optional(),
|
||||
colour: z.string().max(20).nullable().optional(),
|
||||
notifyRecipientIds: z.array(id).max(100).optional().describe('Who gets this campus\'s daily summary (staff only)'),
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const listCampuses = defineRoute({
|
||||
method: 'get', path: '/campuses', tag: 'Campuses', summary: 'The active campuses',
|
||||
description: '`enabled` false means campuses are switched off (the list is then empty).',
|
||||
access: 'public', response: single(z.object({ enabled: z.boolean(), campuses: z.array(publicCampus) })),
|
||||
async handler() { return { data: plain(await campuses.publicCampuses()) }; },
|
||||
});
|
||||
|
||||
export const adminList = defineRoute({
|
||||
method: 'get', path: '/campuses/admin', tag: 'Campuses', summary: 'Every campus (Site settings)',
|
||||
description: 'Archived ones last, with event counts and summary recipients.',
|
||||
access: 'admin', response: single(z.object({ enabled: z.boolean(), campuses: z.array(adminCampus) })),
|
||||
async handler() { return { data: plain(await campuses.adminCampuses()) }; },
|
||||
});
|
||||
|
||||
export const campusBySlug = defineRoute({
|
||||
method: 'get', path: '/campuses/by-slug/:slug', tag: 'Campuses', summary: 'A campus landing page',
|
||||
access: 'public', params: z.object({ slug: z.string().min(1).max(80) }), response: single(publicCampus),
|
||||
async handler({ params }) { return { data: plain(await campuses.campusBySlug(params.slug)) }; },
|
||||
});
|
||||
|
||||
export const setEnabled = defineRoute({
|
||||
method: 'put', path: '/campuses/enabled', tag: 'Campuses', summary: 'Switch campuses on or off',
|
||||
description: 'Off hides campus fields and filters; nothing is deleted.',
|
||||
access: 'admin', body: z.object({ enabled: z.boolean() }), response: single(z.object({ enabled: z.boolean() })),
|
||||
async handler({ body, user, req }) { return { data: await campuses.setCampusesEnabled(body.enabled, actorOf(user), await ctxOf(req)) }; },
|
||||
});
|
||||
|
||||
export const reorder = defineRoute({
|
||||
method: 'put', path: '/campuses/order', tag: 'Campuses', summary: 'Put the campuses in order',
|
||||
access: 'admin', body: z.object({ ids: z.array(id).min(1).max(200) }), response: single(z.object({ ids: z.array(id) })),
|
||||
async handler({ body }) { await campuses.reorderCampuses(body.ids); return { data: { ids: body.ids } }; },
|
||||
});
|
||||
|
||||
export const createCampus = defineRoute({
|
||||
method: 'post', path: '/campuses', tag: 'Campuses', summary: 'Add a campus',
|
||||
access: 'admin', status: 201, body: campusBody, response: single(adminCampus),
|
||||
async handler({ body, user, req }) { return { data: plain(await campuses.createCampus(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateCampus = defineRoute({
|
||||
method: 'patch', path: '/campuses/:id', tag: 'Campuses', summary: 'Change a campus',
|
||||
description: 'Details, summary recipients, or `archived`.',
|
||||
access: 'admin', params: idParams, body: campusBody.partial(), response: single(adminCampus),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await campuses.updateCampus(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteCampus = defineRoute({
|
||||
method: 'delete', path: '/campuses/:id', tag: 'Campuses', summary: 'Delete a campus',
|
||||
description: 'Only one with no events and no staff limited to it — otherwise archive it (CONFLICT, `reason` CAMPUS_HAS_EVENTS or CAMPUS_HAS_SCOPED_STAFF).',
|
||||
access: 'admin', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await campuses.deleteCampus(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const audience = defineRoute({
|
||||
method: 'get', path: '/campuses/:id/audience', tag: 'Campuses', summary: 'Everyone at a campus',
|
||||
description: 'For the email and WhatsApp composers: people whose home campus it is, plus (`registrants=true`) people registered for its events in the last 12 months. News consent is checked when sending.',
|
||||
access: 'supervisor', campusScope: 'scopeList (own campuses only, in campusAudience)', params: idParams,
|
||||
query: z.object({ registrants: z.enum(['true', 'false']).default('false').transform(v => v === 'true') }),
|
||||
response: single(z.object({ campus: z.object({ id, name: z.string() }), users: z.array(z.object({ id, name: z.string(), email: z.string(), phoneNumber: z.string().nullable() })) })),
|
||||
async handler({ params, query, req }) { return { data: plain(await campuses.campusAudience(params.id, query.registrants, await ctxOf(req))) }; },
|
||||
});
|
||||
+24
-2
@@ -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,14 +118,16 @@ 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);
|
||||
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
if (def.file) {
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string | Buffer } }).data;
|
||||
res.setHeader('Content-Type', f.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
|
||||
res.status(def.status ?? 200).send(f.body);
|
||||
@@ -121,4 +143,4 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
}
|
||||
|
||||
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.union([z.string(), z.instanceof(Buffer)]) }) });
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
|
||||
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
|
||||
*
|
||||
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
|
||||
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
|
||||
* v1's `code` becomes `reason`.
|
||||
*
|
||||
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
|
||||
* 1.51.10 did) can follow later without changing the v2 API.
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
import { basename } from 'path';
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
|
||||
/** `url` (with its query string) is for v1 routers, which route by it. */
|
||||
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; url?: string };
|
||||
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
|
||||
|
||||
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
|
||||
class RecordingResponse extends Writable {
|
||||
statusCode = 200;
|
||||
headersSent = false;
|
||||
locals: Record<string, unknown> = {};
|
||||
readonly headers: Record<string, string> = {};
|
||||
private chunks: Buffer[] = [];
|
||||
json?: (v: unknown) => this;
|
||||
answer?: V1Answer;
|
||||
private done!: (a: V1Answer) => void;
|
||||
readonly finished: Promise<V1Answer>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.finished = new Promise(resolve => { this.done = resolve; });
|
||||
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
|
||||
}
|
||||
|
||||
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
|
||||
override _final(cb: () => void) { this.finishWith({}); cb(); }
|
||||
|
||||
private finishWith(extra: Partial<V1Answer>): this {
|
||||
if (this.answer) return this;
|
||||
this.headersSent = true;
|
||||
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
|
||||
this.done(this.answer);
|
||||
return this;
|
||||
}
|
||||
|
||||
status(code: number) { this.statusCode = code; return this; }
|
||||
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
|
||||
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
|
||||
set(name: string | Record<string, unknown>, value?: unknown) {
|
||||
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
|
||||
else this.setHeader(name, value);
|
||||
return this;
|
||||
}
|
||||
header(name: string, value?: unknown) { return this.set(name, value); }
|
||||
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
|
||||
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
|
||||
send(body?: unknown) {
|
||||
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
|
||||
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
|
||||
return this.finishWith({});
|
||||
}
|
||||
// A handler that ends without writing anything still answers.
|
||||
override end(...args: any[]) {
|
||||
const [chunk] = args;
|
||||
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
|
||||
this.finishWith({});
|
||||
return this;
|
||||
}
|
||||
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
|
||||
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
|
||||
const name = typeof filename === 'string' ? filename : basename(filePath);
|
||||
readFile(filePath).then(buf => {
|
||||
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
|
||||
this.setHeader('content-disposition', `attachment; filename="${name}"`);
|
||||
this.chunks.push(buf);
|
||||
this.finishWith({});
|
||||
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
sendFile(filePath: string) {
|
||||
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
redirect(a: number | string, b?: string) {
|
||||
const url = typeof a === 'string' ? a : b!;
|
||||
this.statusCode = typeof a === 'number' ? a : 302;
|
||||
return this.finishWith({ redirect: url });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
|
||||
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
|
||||
* campus scope, read-only state) is the real request's.
|
||||
*/
|
||||
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
|
||||
const fake = Object.create(req) as Request;
|
||||
for (const key of ['params', 'query', 'body', 'url'] as const) {
|
||||
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
|
||||
}
|
||||
const res = new RecordingResponse();
|
||||
const chain = Array.isArray(handlers) ? handlers : [handlers];
|
||||
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
|
||||
let advanced = false;
|
||||
const next = (err?: unknown) => {
|
||||
advanced = true;
|
||||
if (err) return reject(err);
|
||||
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
|
||||
// Nothing answered (e.g. a v1 router with no matching route).
|
||||
else reject(Object.assign(new Error('There is no such API endpoint.'), { statusCode: 404 }));
|
||||
};
|
||||
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
|
||||
});
|
||||
await Promise.race([step(0).then(() => res.finished), res.finished]);
|
||||
return res.answer!;
|
||||
}
|
||||
|
||||
/** A v1 error answer as something v2's error handler understands. */
|
||||
function v1Error(a: V1Answer): Error {
|
||||
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
|
||||
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
|
||||
const e = new Error(text) as Error & { statusCode: number; code?: string };
|
||||
e.statusCode = a.status;
|
||||
if (typeof j.code === 'string') e.code = j.code;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** `{ data: <anything> }` — for v1 answers documented by their description. */
|
||||
export const anyData = z.object({ data: z.unknown() });
|
||||
export const listData = z.object({ data: z.array(z.looseObject({})) });
|
||||
export const objectData = z.object({ data: z.looseObject({}) });
|
||||
|
||||
type V1RouteDef = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
path: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
access: Access;
|
||||
campusScope?: string;
|
||||
middleware?: RequestHandler[];
|
||||
params?: z.ZodType;
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response?: z.ZodType;
|
||||
/** 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 };
|
||||
/** The v1 handler (or middleware chain ending in one). */
|
||||
v1: RequestHandler | RequestHandler[];
|
||||
/**
|
||||
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
|
||||
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
|
||||
*/
|
||||
map?: (ctx: Ctx<any, any, any>) => Parts;
|
||||
};
|
||||
|
||||
/** A v2 route answered by a v1 handler. */
|
||||
export function v1Route(def: V1RouteDef) {
|
||||
const { v1, map, response, ...rest } = def;
|
||||
return defineRoute({
|
||||
...rest,
|
||||
response: def.file ? fileResponse : (response ?? anyData),
|
||||
async handler(ctx) {
|
||||
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
|
||||
const a = await runV1(v1, ctx.req, parts);
|
||||
if (a.status >= 400) throw v1Error(a);
|
||||
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
|
||||
if (def.file) {
|
||||
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
|
||||
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
|
||||
}
|
||||
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,134 @@
|
||||
// AI helpers (2.0): what the buttons do, and how a helper call goes through the Hub (§3b).
|
||||
|
||||
const mockCalls: Array<Record<string, unknown>> = [];
|
||||
const mockSettings = new Map<string, string>();
|
||||
const mockDb = {
|
||||
appSetting: {
|
||||
findUnique: jest.fn(async ({ where }: { where: { key: string } }) => (mockSettings.has(where.key) ? { key: where.key, value: mockSettings.get(where.key) } : null)),
|
||||
upsert: jest.fn(async ({ where, update }: { where: { key: string }; update: { value: string } }) => { mockSettings.set(where.key, update.value); }),
|
||||
},
|
||||
aiCall: {
|
||||
count: jest.fn(async () => mockCalls.filter(c => c.outcome === 'ok').length),
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => { mockCalls.push(data); return data; }),
|
||||
deleteMany: jest.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
user: { findMany: jest.fn(async () => [{ email: 'admin@test.local' }]) },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
|
||||
const mockLicence = { connected: true, readOnly: false, state: 'active', claims: { limits: { aiMonthlyCapCents: 5000 } } as { limits: { aiMonthlyCapCents: number } } | null };
|
||||
jest.mock('../src/hub/licence', () => ({ licenceStatus: jest.fn(async () => mockLicence) }));
|
||||
|
||||
class MockHubError extends Error {
|
||||
constructor(message: string, public code: string, public statusCode: number, public data: unknown = null) { super(message); }
|
||||
}
|
||||
const mockPost = jest.fn();
|
||||
jest.mock('../src/hub/client', () => ({ PROTOCOL_VERSION: 1, HubError: MockHubError, signedPost: (...a: unknown[]) => mockPost(...a) }));
|
||||
const mockMail = jest.fn(async (_m: unknown) => undefined);
|
||||
jest.mock('../src/utils/email', () => ({ sendMail: (m: unknown) => mockMail(m), emailWrapper: (s: string) => s, isSmtpConfigured: () => true }));
|
||||
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn(async () => undefined) }));
|
||||
|
||||
import { aiStatus, runHelper, saveAiSettings, clearAiStatusCache, _internal } from '../src/ai';
|
||||
|
||||
const hubStatus = (over = {}) => ({ enabled: true, capReached: false, capCents: 5000, usedCents: 100, usedPercent: 2, renewsOn: '2026-12-01', ...over });
|
||||
const req = { system: 'Write a friendly message.', input: 'Bring a sleeping bag.', maxTokens: 500 };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCalls.length = 0;
|
||||
mockSettings.clear();
|
||||
mockPost.mockReset();
|
||||
mockMail.mockClear();
|
||||
mockLicence.connected = true;
|
||||
mockLicence.readOnly = false;
|
||||
mockLicence.claims = { limits: { aiMonthlyCapCents: 5000 } };
|
||||
clearAiStatusCache();
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
test('a licence without AI never asks the Hub', async () => {
|
||||
mockLicence.claims = { limits: { aiMonthlyCapCents: 0 } };
|
||||
expect(await aiStatus()).toMatchObject({ enabled: false, capReached: false });
|
||||
mockLicence.connected = false;
|
||||
expect((await aiStatus()).enabled).toBe(false);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('asks the Hub and caches its answer for a minute', async () => {
|
||||
mockPost.mockResolvedValue(hubStatus());
|
||||
expect(await aiStatus()).toMatchObject({ enabled: true, available: true, usedCents: 100, helpers: { draft_event: true } });
|
||||
await aiStatus();
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/installs/ai/status', { protocolVersion: 1 });
|
||||
});
|
||||
|
||||
test('a Hub without AI (404) means off; an unreachable Hub keeps the buttons, marked unavailable', async () => {
|
||||
mockPost.mockRejectedValueOnce(new MockHubError('Not found', 'NOT_FOUND', 404));
|
||||
expect((await aiStatus()).enabled).toBe(false);
|
||||
clearAiStatusCache();
|
||||
mockPost.mockRejectedValueOnce(new MockHubError('down', 'HUB_UNREACHABLE', 502));
|
||||
expect(await aiStatus()).toMatchObject({ enabled: true, available: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('running a helper', () => {
|
||||
test('sends the request to the Hub, logs the call, returns the answer', async () => {
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'Hi all!', chargedCents: 0.31, status: hubStatus({ usedCents: 100.31 }) });
|
||||
const r = await runHelper('draft_message', 'u1', req);
|
||||
expect(r.output).toBe('Hi all!');
|
||||
expect(r.status.usedCents).toBe(100.31);
|
||||
expect(mockPost).toHaveBeenLastCalledWith('/api/installs/ai/complete', { protocolVersion: 1, helper: 'draft_message', ...req }, { timeoutMs: 90000 });
|
||||
expect(mockCalls).toEqual([{ userId: 'u1', helper: 'draft_message', outcome: 'ok', chargedCents: 0.31 }]);
|
||||
});
|
||||
|
||||
test('a helper switched off, or the cap reached, never reaches the Hub', async () => {
|
||||
mockPost.mockResolvedValue(hubStatus({ capReached: true }));
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
|
||||
clearAiStatusCache();
|
||||
mockPost.mockResolvedValue(hubStatus());
|
||||
await saveAiSettings({ helpers: { draft_message: false } }, { id: 'a1', role: 'admin' });
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_HELPER_OFF' });
|
||||
expect(mockPost).not.toHaveBeenCalledWith('/api/installs/ai/complete', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
test('the per-person daily limit', async () => {
|
||||
await saveAiSettings({ dailyLimitPerPerson: 1 }, { id: 'a1', role: 'admin' });
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'x', chargedCents: 0.1, status: hubStatus() });
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'RATE_LIMITED', reason: 'AI_DAILY_LIMIT' });
|
||||
});
|
||||
|
||||
test('the Hub refusing at the cap updates the status, so the buttons disable at once', async () => {
|
||||
mockPost.mockResolvedValueOnce(hubStatus())
|
||||
.mockRejectedValueOnce(new MockHubError('used up', 'AI_CAP_REACHED', 402, { code: 'AI_CAP_REACHED', status: hubStatus({ capReached: true }) }));
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_CAP_REACHED', message: expect.stringContaining('1 December') });
|
||||
expect((await aiStatus()).capReached).toBe(true);
|
||||
expect(mockCalls[0]).toMatchObject({ outcome: 'cap_reached', chargedCents: 0 });
|
||||
});
|
||||
|
||||
test('admins are emailed once a month at 80%', async () => {
|
||||
const at80 = hubStatus({ usedPercent: 81, usedCents: 4050 });
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValue({ output: 'x', chargedCents: 1, status: at80 });
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
expect(mockMail).toHaveBeenCalledTimes(1);
|
||||
expect(mockMail.mock.calls[0]).toEqual([expect.objectContaining({ to: 'admin@test.local', subject: 'AI allowance 81% used' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hub errors', () => {
|
||||
test.each([
|
||||
['AI_REFUSED', 'UNAVAILABLE', 'refused'],
|
||||
['AI_INCOMPLETE', 'UNAVAILABLE', 'incomplete'],
|
||||
['AI_OFF', 'FORBIDDEN', 'unavailable'],
|
||||
['HUB_UNREACHABLE', 'UNAVAILABLE', 'unavailable'],
|
||||
])('%s → %s, logged as %s', (hubCode, code, outcome) => {
|
||||
const r = _internal.fromHubError({ code: hubCode, message: 'x' });
|
||||
expect(r.error.code).toBe(code);
|
||||
expect(r.outcome).toBe(outcome);
|
||||
});
|
||||
|
||||
test('today starts at midnight South African time', () => {
|
||||
expect(_internal.startOfToday(new Date('2026-11-10T22:30:00Z')).toISOString()).toBe('2026-11-10T22:00:00.000Z');
|
||||
expect(_internal.startOfToday(new Date('2026-11-10T10:00:00Z')).toISOString()).toBe('2026-11-09T22:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// The five AI helpers (2.0): what they send, what they keep back, and the checks done in code.
|
||||
|
||||
const mockRun = jest.fn();
|
||||
jest.mock('../src/ai/index', () => ({ runHelper: (...a: unknown[]) => mockRun(...a) }));
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
import {
|
||||
todayLine, protect, restore, weekdayClashes, eventWarnings, reportText, feedbackText,
|
||||
draftEvent, summariseReport, draftMessage, improveText, type EventDraftAnswer,
|
||||
} from '../src/ai/helpers';
|
||||
|
||||
const status = { enabled: true, capReached: false, available: true, capCents: 5000, usedCents: 1, usedPercent: 0, renewsOn: '2026-12-01', helpers: {} };
|
||||
const now = new Date('2026-10-30T08:00:00Z');
|
||||
|
||||
beforeEach(() => mockRun.mockReset());
|
||||
|
||||
const camp: EventDraftAnswer = {
|
||||
title: 'Rooted Youth Camp', summary: '', description: '', startDate: '2027-03-14', startTime: '18:00', endDate: '2027-03-16', endTime: '14:00',
|
||||
location: 'Camp Wartburg', capacity: 60, needsParentalConsent: true, ageGroup: 'Grades 8–12',
|
||||
ticketOptions: [{ name: 'Camper', priceRand: 450, description: '', earlyBirdPriceRand: 400, earlyBirdUntil: '2027-01-31' }],
|
||||
sessions: [],
|
||||
costs: [
|
||||
{ item: 'Venue', basis: 'R180 × 60 × 2', amountRand: 21600 }, { item: 'Bus', basis: '', amountRand: 6500 },
|
||||
{ item: 'Speaker gift', basis: '', amountRand: 1000 }, { item: 'T-shirts', basis: 'R95 × 60', amountRand: 5700 },
|
||||
],
|
||||
checks: [],
|
||||
};
|
||||
|
||||
describe('today and contact details', () => {
|
||||
test('today is given in South African time', () => {
|
||||
expect(todayLine(now)).toBe('Today is Friday 30 October 2026.');
|
||||
expect(todayLine(new Date('2026-10-30T22:30:00Z'))).toBe('Today is Saturday 31 October 2026.');
|
||||
});
|
||||
|
||||
test('emails, phone numbers and ID numbers never leave the site, and come back in the answer', () => {
|
||||
const p = protect('Call Sarah on 082 555 1234 or +27 21 555 9876, email youth@church.co.za. ID 9001015009087. Bus R6 500 at 07:30.');
|
||||
// Emails are found first, then phone numbers, then ID numbers.
|
||||
expect(p.text).toBe('Call Sarah on [[CONTACT2]] or [[CONTACT3]], email [[CONTACT1]]. ID [[CONTACT4]]. Bus R6 500 at 07:30.');
|
||||
expect(restore({ body: 'Questions? [[CONTACT1]] or [[CONTACT2]].', list: ['[[CONTACT3]]'] }, p.found))
|
||||
.toEqual({ body: 'Questions? youth@church.co.za or 082 555 1234.', list: ['+27 21 555 9876'] });
|
||||
});
|
||||
|
||||
test('the same detail twice gets one placeholder', () => {
|
||||
expect(protect('a@b.co.za and again a@b.co.za').text).toBe('[[CONTACT1]] and again [[CONTACT1]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks done in code', () => {
|
||||
test('a weekday that does not match its date', () => {
|
||||
expect(weekdayClashes('Youth camp Fri 14 March to Sun 16 March', 2027)).toEqual([
|
||||
'The notes say Friday 14 March, but 14 March 2027 is a Sunday. Check the dates.',
|
||||
'The notes say Sunday 16 March, but 16 March 2027 is a Tuesday. Check the dates.',
|
||||
]);
|
||||
expect(weekdayClashes('Fri 14 March', 2025)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an event that loses money even when full', () => {
|
||||
expect(eventWarnings('', camp)).toEqual([
|
||||
'The costs in the notes come to R34 800, which needs 78 places at R450 — more than the 60 available. Even full, the event takes R27 000 (R24 000 if everyone books early), R7 800 short.',
|
||||
]);
|
||||
expect(eventWarnings('', { ...camp, capacity: 80 })).toEqual([]);
|
||||
expect(eventWarnings('', { ...camp, endDate: '2027-03-13' })).toContain('The end is before the start.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the helpers send', () => {
|
||||
test('draft an event: today\'s date, a schema, contact details kept back; code warnings added', async () => {
|
||||
mockRun.mockResolvedValue({ output: { ...camp, description: 'Questions? [[CONTACT1]]' }, status });
|
||||
const r = await draftEvent('u1', 'Youth camp Fri 14 March, R450, 60 places. Questions: youth@church.co.za', now);
|
||||
const [helper, user, req] = mockRun.mock.calls[0];
|
||||
expect([helper, user]).toEqual(['draft_event', 'u1']);
|
||||
expect(req.system).toContain('Today is Friday 30 October 2026.');
|
||||
expect(req.input).not.toContain('youth@church.co.za');
|
||||
expect(req.schema.required).toContain('ticketOptions');
|
||||
expect(r.draft.description).toBe('Questions? youth@church.co.za');
|
||||
expect(r.totalCostRand).toBe(34800);
|
||||
expect(r.breakEvenPlaces).toBe(78);
|
||||
expect(r.warnings.some(w => w.includes('is a Sunday'))).toBe(true);
|
||||
expect(r.warnings.some(w => w.includes('R7 800 short'))).toBe(true);
|
||||
});
|
||||
|
||||
test('summarise a report: only reports without people', async () => {
|
||||
await expect(summariseReport('u1', { reportKey: 'payments', title: 'Payments', columns: ['User'], rows: [['Jane']] })).rejects.toMatchObject({ reason: 'AI_REPORT_HAS_PEOPLE' });
|
||||
expect(mockRun).not.toHaveBeenCalled();
|
||||
mockRun.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status });
|
||||
await summariseReport('u1', { reportKey: 'revenue', title: 'Revenue Summary', stats: [{ label: 'Total', value: 'R66 550' }], columns: ['Event', 'Amount'], rows: [['Camp', 'R16 650']] }, now);
|
||||
expect(mockRun.mock.calls[0][2].input).toBe('Report: Revenue Summary\nTotal: R66 550\nColumns: Event | Amount\nCamp | R16 650');
|
||||
});
|
||||
|
||||
test('report text is capped at 400 rows', () => {
|
||||
const t = reportText({ reportKey: 'usage', title: 'T', columns: ['A'], rows: Array.from({ length: 450 }, (_, i) => [i]) });
|
||||
expect(t).toContain('(50 more rows not shown)');
|
||||
expect(t.split('\n')).toHaveLength(2 + 400 + 1);
|
||||
});
|
||||
|
||||
test('feedback text: averages, counts and numbered comments, no names', () => {
|
||||
expect(feedbackText({ title: 'Women\'s Conference' }, 112, 186, [
|
||||
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 },
|
||||
{ label: 'Come again?', type: 'yes_no', answered: 100, counts: [{ label: 'Yes', value: 90 }, { label: 'No', value: 10 }] },
|
||||
{ label: 'Comments', type: 'text', answered: 2, texts: ['Hall B sound please fix it.', 'Loved the worship!'] },
|
||||
])).toBe([
|
||||
'Event: Women\'s Conference', 'Responses: 112 of 186 asked', 'Overall — average 4.4 (112 answers)', 'Come again? — Yes: 90, No: 10',
|
||||
'Comments — comments:', '1. Hall B sound please fix it.', '2. Loved the worship!',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('draft a message: WhatsApp has no subject; the event is given as facts', async () => {
|
||||
mockRun.mockResolvedValue({ output: { subject: 'ignored', body: 'Hi {{name}}!' }, status });
|
||||
const r = await draftMessage('u1', { channel: 'whatsapp', note: 'bring a sleeping bag', event: { title: 'Youth Camp', start: 'Friday 14 March 2027, 18:00', location: 'Camp Wartburg' } }, now);
|
||||
expect(r.message).toEqual({ subject: '', body: 'Hi {{name}}!' });
|
||||
expect(mockRun.mock.calls[0][2].input).toBe('Note: bring a sleeping bag\n\nEvent: Youth Camp, Friday 14 March 2027, 18:00, at Camp Wartburg');
|
||||
expect(mockRun.mock.calls[0][2].system).toContain('WhatsApp');
|
||||
expect(mockRun.mock.calls[0][2].system).toContain('{{event.title}}');
|
||||
});
|
||||
|
||||
test('draft a message without an event: no event placeholders offered', async () => {
|
||||
mockRun.mockResolvedValue({ output: { subject: 'S', body: 'Hi {{name}}' }, status });
|
||||
await draftMessage('u1', { channel: 'email', note: 'the breakfast moves to the hotel' }, now);
|
||||
const system = mockRun.mock.calls[0][2].system;
|
||||
expect(system).toContain('Use no other {{…}} placeholders');
|
||||
expect(system).not.toContain('{{event.title}}');
|
||||
});
|
||||
|
||||
test('improve text: the mode and where it goes shape the instructions', async () => {
|
||||
mockRun.mockResolvedValue({ output: { text: 'Fixed.', changes: [] }, status });
|
||||
await improveText('u1', { text: 'join us for are anual fun day', mode: 'fix', where: 'banner' });
|
||||
const req = mockRun.mock.calls[0][2];
|
||||
expect(req.system).toContain('a short banner across the top of the website');
|
||||
expect(req.system).toContain('Only fix spelling, grammar and punctuation');
|
||||
});
|
||||
});
|
||||
@@ -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 }) });
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,48 @@ describe('read-only mode', () => {
|
||||
['POST', '/api/users/login'],
|
||||
['POST', '/api/reports/pdf'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
|
||||
// 1.52.3: the screens use /api/v2, so its routes must be blocked too.
|
||||
test.each([
|
||||
['POST', '/api/v2/events'],
|
||||
['PATCH', '/api/v2/events/ev_1'],
|
||||
['POST', '/api/v2/events/ev_1/copy-from'],
|
||||
['POST', '/api/v2/events/ev_1/ticket-options'],
|
||||
['PATCH', '/api/v2/ticket-options/opt_1'],
|
||||
['PATCH', '/api/v2/variants/var_1'],
|
||||
['POST', '/api/v2/registrations'],
|
||||
['POST', '/api/v2/registrations/manual'],
|
||||
['PUT', '/api/v2/registrations/abc/items'],
|
||||
['POST', '/api/v2/registrations/abc/checkout'],
|
||||
['POST', '/api/v2/registrations/abc/payment-link/send'],
|
||||
['POST', '/api/v2/payments'],
|
||||
['POST', '/api/v2/payment-links/chk_1'],
|
||||
['POST', '/api/v2/waitlist'],
|
||||
['POST', '/api/v2/season-passes/p1/buy'],
|
||||
['POST', '/api/v2/volunteer-roles/r1/sign-up'],
|
||||
])('v2 %s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
|
||||
|
||||
test.each([
|
||||
['POST', '/api/v2/checkin/QR123'],
|
||||
['GET', '/api/v2/events'],
|
||||
['PUT', '/api/v2/events/ev_1/notify-recipients'],
|
||||
['POST', '/api/v2/registrations/abc/cancel'],
|
||||
['POST', '/api/v2/payments/refunds'],
|
||||
['POST', '/api/v2/me/privacy/requests'],
|
||||
['POST', '/api/v2/auth/login'],
|
||||
])('v2 %s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
|
||||
test('every blocked v2 path is a real v2 route', () => {
|
||||
const doc = require('../../frontend/src/lib/api-v2/openapi.json');
|
||||
const routes = Object.entries(doc.paths).flatMap(([p, ops]) =>
|
||||
Object.keys(ops).map(m => [m.toUpperCase(), `/api/v2${p.replace(/\{[^}]+\}/g, 'x1')}`]));
|
||||
const { BLOCKED } = require('../src/hub/readOnly');
|
||||
const unmatched = BLOCKED
|
||||
.filter(([, re]) => re.source.startsWith('^\\/api\\/v2'))
|
||||
.filter(([m, re]) => !routes.some(([rm, rp]) => rm === m && re.test(rp)))
|
||||
.map(([m, re]) => `${m} ${re}`);
|
||||
expect(unmatched).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pasting the Hub public key', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
+225
-1
@@ -5,11 +5,17 @@ import request from 'supertest';
|
||||
// way everywhere, and produce the OpenAPI document.
|
||||
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
|
||||
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||
feedbackForm: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
// AI helpers: everything real except the call to the Hub.
|
||||
const mockRunHelper = jest.fn();
|
||||
jest.mock('../src/ai/index', () => ({ ...jest.requireActual('../src/ai/index'), runHelper: (...a: unknown[]) => mockRunHelper(...a) }));
|
||||
const mockAccessLog = jest.fn(async () => undefined);
|
||||
jest.mock('../src/utils/accessLog', () => ({ logDataAccess: (...a: unknown[]) => mockAccessLog(...(a as [])) }));
|
||||
jest.mock('../src/utils/campuses', () => ({
|
||||
campusesEnabled: jest.fn().mockResolvedValue(false),
|
||||
resolveCampusFilter: jest.fn(async (v: string) => v),
|
||||
@@ -50,6 +56,10 @@ jest.mock('../src/utils/campusScope', () => ({
|
||||
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
|
||||
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
listWhere: async () => ({}),
|
||||
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
scopeBroadcast: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
campusIndependent: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
|
||||
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
|
||||
@@ -75,6 +85,25 @@ jest.mock('../src/controllers/uploadController', () => {
|
||||
return { upload: m, uploadLogo: m, uploadFavicon: m };
|
||||
});
|
||||
|
||||
const mockSeries = { listSeries: jest.fn(), createSeries: jest.fn(), updateSeries: jest.fn(), deleteSeries: jest.fn(), seriesPage: jest.fn(), compareSeries: jest.fn(), setFollowing: jest.fn(), listPasses: jest.fn(), createPass: jest.fn(), updatePass: jest.fn(), deletePass: jest.fn(), buyPass: jest.fn(), payPassPurchase: jest.fn(), myPassPurchases: jest.fn() };
|
||||
jest.mock('../src/services/series', () => Object.fromEntries(Object.keys(mockSeries).map(k => [k, (...a: unknown[]) => (mockSeries as Record<string, jest.Mock>)[k](...a)])));
|
||||
const mockDeals = { listBundles: jest.fn(), createBundle: jest.fn(), updateBundle: jest.fn(), deleteBundle: jest.fn(), quoteBundles: jest.fn(), checkCode: jest.fn(), listCoupons: jest.fn(), getCoupon: jest.fn(), createCoupon: jest.fn(), updateCoupon: jest.fn(), listRedemptions: jest.fn(), publicBundlesForEvent: jest.fn() };
|
||||
jest.mock('../src/services/deals', () => Object.fromEntries(Object.keys(mockDeals).map(k => [k, (...a: unknown[]) => (mockDeals as Record<string, jest.Mock>)[k](...a)])));
|
||||
const mockCampuses = { publicCampuses: jest.fn(), campusBySlug: jest.fn(), adminCampuses: jest.fn(), setCampusesEnabled: jest.fn(), createCampus: jest.fn(), updateCampus: jest.fn(), reorderCampuses: jest.fn(), deleteCampus: jest.fn(), campusAudience: jest.fn() };
|
||||
jest.mock('../src/services/campuses', () => Object.fromEntries(Object.keys(mockCampuses).map(k => [k, (...a: unknown[]) => (mockCampuses as Record<string, jest.Mock>)[k](...a)])));
|
||||
|
||||
const mockAdmin = { getRegistrations: jest.fn(), getUserRegistrations: jest.fn(), getRegistrationById: jest.fn(), updateRegistrationStatus: jest.fn(), getRegistrationsByEvent: jest.fn(), createManualRegistration: jest.fn(), updateRegistrationOptions: jest.fn(), submitFormResponses: jest.fn(), replaceFormResponses: jest.fn(), getFormDraft: jest.fn(), saveFormDraft: jest.fn(), sendInvoice: jest.fn(), applyCoupon: jest.fn(), removeCoupon: jest.fn(), updateAttendees: jest.fn(), sendAttendeeFormLink: jest.fn() };
|
||||
jest.mock('../src/services/registrationAdmin', () => Object.fromEntries(Object.keys(mockAdmin).map(k => [k, (...a: unknown[]) => (mockAdmin as Record<string, jest.Mock>)[k](...a)])));
|
||||
jest.mock('../src/utils/seasonPass', () => ({ notOnSeasonPass: (_req: unknown, _res: unknown, next: () => void) => next() }));
|
||||
|
||||
jest.mock('../src/controllers/cashupController', () => {
|
||||
const echo = (req: { params: object; body: object }, res: { json(b: unknown): void }) => res.json({ params: req.params, body: req.body });
|
||||
return {
|
||||
getEventCashup: echo, getCashByRecordedUser: echo, savePersonCash: echo, saveEventCashupDraft: echo, reopenEvent: echo, resolvePending: echo, getCashupAudit: echo,
|
||||
closeEvent: (_req: unknown, res: { status(n: number): { json(b: unknown): void } }) => res.status(400).json({ message: 'There are pending lines.', code: 'PENDING_LINES' }),
|
||||
};
|
||||
});
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
@@ -554,3 +583,198 @@ describe('sections, costs, image uploads', () => {
|
||||
expect((await request(app).post('/api/v2/uploads/logo')).body.data.path).toBe('/uploads/branding/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('series, deals and campuses', () => {
|
||||
test('following a series needs an account; the page works without one', async () => {
|
||||
expect((await request(app).put('/api/v2/series/s1/follow')).status).toBe(401);
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockSeries.setFollowing.mockResolvedValue({ following: true });
|
||||
expect((await request(app).put('/api/v2/series/s1/follow')).body.data).toEqual({ following: true });
|
||||
expect(mockSeries.setFollowing).toHaveBeenCalledWith('s1', 'u1', true);
|
||||
});
|
||||
|
||||
test('a coupon check is always a 200 with valid true or false', async () => {
|
||||
mockDeals.checkCode.mockResolvedValueOnce({ valid: false, message: 'This code has expired.' });
|
||||
const res = await request(app).post('/api/v2/coupons/check').send({ code: 'OLD', eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual({ valid: false, message: 'This code has expired.' });
|
||||
});
|
||||
|
||||
test('/coupons/redemptions is not read as a coupon id', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockDeals.listRedemptions.mockResolvedValue([]);
|
||||
expect((await request(app).get('/api/v2/coupons/redemptions?eventIds=e1,e2')).body).toEqual({ data: [] });
|
||||
expect(mockDeals.listRedemptions.mock.calls[0][0]).toEqual({ eventIds: ['e1', 'e2'], from: null, to: null });
|
||||
expect(mockDeals.getCoupon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a campus that can't be deleted says why, as a reason", async () => {
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
mockCampuses.deleteCampus.mockRejectedValueOnce(new ServiceError(409, 'North has 3 events, so it can\'t be deleted. Archive it instead.', 'CAMPUS_HAS_EVENTS'));
|
||||
const res = await request(app).delete('/api/v2/campuses/c1');
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toEqual({ code: 'CONFLICT', message: expect.stringContaining('Archive it instead'), reason: 'CAMPUS_HAS_EVENTS' });
|
||||
});
|
||||
|
||||
test('campus admin routes are for admins; the public list is for anyone', async () => {
|
||||
mockCampuses.publicCampuses.mockResolvedValue({ enabled: true, campuses: [{ id: 'c1', name: 'North', slug: 'north', colour: null }] });
|
||||
expect((await request(app).get('/api/v2/campuses')).body.data.campuses[0]).toMatchObject({ slug: 'north' });
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).get('/api/v2/campuses/admin')).status).toBe(403);
|
||||
expect((await request(app).put('/api/v2/campuses/enabled').send({ enabled: true })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managing registrations', () => {
|
||||
const reg = { id: 'r1', status: 'pending', eventId: 'e1', userId: 'u1', totalDueComputed: 150, payments: [] };
|
||||
|
||||
test('a guest with the link can read a registration in full', async () => {
|
||||
mockAdmin.getRegistrationById.mockResolvedValue(reg);
|
||||
const res = await request(app).get('/api/v2/registrations/r1/full');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', totalDueComputed: 150, payments: [] });
|
||||
expect(mockAdmin.getRegistrationById.mock.calls[0][0]).toMatchObject({ actor: null, params: { id: 'r1' } });
|
||||
});
|
||||
|
||||
test('the staff list is paged and campus-scoped', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/registrations')).status).toBe(403);
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockAdmin.getRegistrations.mockResolvedValue({ data: [reg], total: 1, page: 1, limit: 100, pages: 1 });
|
||||
const res = await request(app).get('/api/v2/registrations?unpaid=true');
|
||||
expect(res.body).toEqual({ data: [reg], meta: { page: 1, limit: 100, total: 1, pages: 1 } });
|
||||
const call = mockAdmin.getRegistrations.mock.calls[0][0];
|
||||
expect(call.query).toMatchObject({ unpaid: '1', page: '1', limit: '100' });
|
||||
expect(typeof call.listWhere).toBe('function');
|
||||
});
|
||||
|
||||
test('a status must be one of the five', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
const res = await request(app).patch('/api/v2/registrations/r1/status').send({ status: 'refunded' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe('VALIDATION_FAILED');
|
||||
expect(mockAdmin.updateRegistrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('registering someone at the door is for supervisors, and answers 201', async () => {
|
||||
const body = { eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 2 }], user: { name: 'Thandi', phoneNumber: '0821234567' } };
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
expect((await request(app).post('/api/v2/registrations/manual').send(body)).status).toBe(403);
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockAdmin.createManualRegistration.mockResolvedValue(reg);
|
||||
const res = await request(app).post('/api/v2/registrations/manual').send(body);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAdmin.createManualRegistration.mock.calls[0][0].body).toMatchObject(body);
|
||||
});
|
||||
|
||||
test("a refusal keeps the service's status", async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockAdmin.updateRegistrationOptions.mockRejectedValueOnce(new ServiceError(400, 'Cannot reduce items below the amount already paid'));
|
||||
const res = await request(app).put('/api/v2/registrations/r1/items').send({ options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatchObject({ code: 'VALIDATION_FAILED', message: 'Cannot reduce items below the amount already paid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('money (v1 handlers behind v2 routes)', () => {
|
||||
test("an event's cash-up reaches v1 with the event as :eventId", async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
const res = await request(app).get('/api/v2/events/e1/cashup');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.params).toMatchObject({ eventId: 'e1' });
|
||||
});
|
||||
|
||||
test('closing is for admins, and a v1 refusal keeps its code as the reason', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).post('/api/v2/events/e1/cashup/close').send({})).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/events/e1/cashup/close').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toEqual({ code: 'VALIDATION_FAILED', message: 'There are pending lines.', reason: 'PENDING_LINES' });
|
||||
});
|
||||
|
||||
test('a cash-up line is given to v1 as :lineId', async () => {
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/cashup-lines/l9/pending').send({ resolution: 'paid' });
|
||||
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI helpers (2.0)', () => {
|
||||
const st = { enabled: true, capReached: false, available: true, capCents: 5000, usedCents: 1, usedPercent: 0, renewsOn: '2026-12-01', helpers: { draft_event: true, summarise_report: true, draft_message: true, summarise_feedback: true, improve_text: true } };
|
||||
|
||||
test('supervisors only', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
expect((await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there' })).status).toBe(403);
|
||||
expect(mockRunHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('improve text: the answer and the new status', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
mockRunHelper.mockResolvedValue({ output: { text: 'Hello there.', changes: ['Capital letter'] }, status: st });
|
||||
const res = await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there', mode: 'fix', where: 'banner' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual({ output: { text: 'Hello there.', changes: ['Capital letter'] }, status: st });
|
||||
expect(mockRunHelper.mock.calls[0][0]).toBe('improve_text');
|
||||
});
|
||||
|
||||
test('summarise a report: reports with people in them are refused by validation', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
const res = await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'payments', title: 'Payments', columns: ['User'], rows: [['Jane']] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockRunHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('draft an event: the draft with warnings worked out in code', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
const draft = {
|
||||
title: 'Camp', summary: '', description: '', startDate: '2027-03-14', startTime: '18:00', endDate: '2027-03-16', endTime: '14:00', location: '', capacity: 60, needsParentalConsent: true, ageGroup: '',
|
||||
ticketOptions: [{ name: 'Camper', priceRand: 450, description: '', earlyBirdPriceRand: 0, earlyBirdUntil: '' }], sessions: [],
|
||||
costs: [{ item: 'Venue', basis: '', amountRand: 34800 }], checks: [],
|
||||
};
|
||||
mockRunHelper.mockResolvedValue({ output: draft, status: st });
|
||||
const res = await request(app).post('/api/v2/ai/draft-event').send({ notes: 'Camp Fri 14 March, R450, 60 places' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.output).toMatchObject({ totalCostRand: 34800, breakEvenPlaces: 78 });
|
||||
expect(res.body.data.output.warnings.join(' ')).toMatch(/is a Sunday.*R7 800 short/);
|
||||
});
|
||||
|
||||
test('draft a message: the event is given as facts', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
mockDb.event.findUnique.mockResolvedValue({ title: 'Youth Camp', startDate: new Date('2027-03-12T16:00:00Z'), location: 'Camp Wartburg' });
|
||||
mockRunHelper.mockResolvedValue({ output: { subject: '', body: 'Hi {{name}}!' }, status: st });
|
||||
const res = await request(app).post('/api/v2/ai/draft-message').send({ channel: 'whatsapp', note: 'bring a sleeping bag', eventId: '11111111-1111-4111-8111-111111111111' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.output).toEqual({ subject: '', body: 'Hi {{name}}!' });
|
||||
expect(mockRunHelper.mock.calls[0][2].input).toContain('Event: Youth Camp');
|
||||
});
|
||||
|
||||
test('summarise feedback: answers without who gave them, and an access-log entry', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue({
|
||||
event: { title: 'Conference' }, _count: { invites: 10 },
|
||||
fields: [{ id: 'f1', type: 'rating', label: 'Overall' }, { id: 'f2', type: 'textarea', label: 'Comments' }],
|
||||
responses: [
|
||||
{ userId: 'u1', user: { name: 'Jane Doe' }, answers: [{ fieldId: 'f1', value: '5' }, { fieldId: 'f2', value: 'Loved it, call me on 082 555 1234' }] },
|
||||
{ userId: 'u2', user: { name: 'John Roe' }, answers: [{ fieldId: 'f1', value: '3' }] },
|
||||
],
|
||||
});
|
||||
mockRunHelper.mockResolvedValue({ output: { overall: 'Good.', liked: [], improve: [], quotes: [], nextTime: [] }, status: st });
|
||||
const res = await request(app).post('/api/v2/ai/summarise-feedback').send({ eventId: '11111111-1111-4111-8111-111111111111' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.output.responses).toBe(2);
|
||||
const sent = mockRunHelper.mock.calls[0][2].input;
|
||||
expect(sent).toContain('Overall — average 4');
|
||||
expect(sent).not.toMatch(/Jane|John|082 555 1234/);
|
||||
expect(mockAccessLog).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'ai_feedback', records: 2 }));
|
||||
});
|
||||
|
||||
test('Hub errors keep their reason', async () => {
|
||||
signedIn = { id: 's1', role: 'supervisor' };
|
||||
const { ApiError } = jest.requireActual('../src/v2/errors');
|
||||
mockRunHelper.mockRejectedValue(Object.assign(new ApiError('FORBIDDEN', 'Used up.'), { reason: 'AI_CAP_REACHED' }));
|
||||
const res = await request(app).post('/api/v2/ai/improve-text').send({ text: 'hello there' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { PassThrough } from 'stream';
|
||||
import { z } from 'zod';
|
||||
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
jest.mock('../src/middleware/authMiddleware', () => ({ protect: jest.fn(), optionalAuth: jest.fn(), admin: jest.fn(), staff: jest.fn(), supervisor: jest.fn() }));
|
||||
|
||||
import { mount } from '../src/v2/route';
|
||||
import { errorHandler } from '../src/v2/errors';
|
||||
import { v1Route } from '../src/v2/viaV1';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
function appWith(...defs: ReturnType<typeof v1Route>[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
mount(router, defs);
|
||||
app.use('/api/v2', router);
|
||||
app.use('/api/v2', errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('v2 routes answered by v1 handlers', () => {
|
||||
test("v1's JSON becomes data; v2's :id is given to v1 by its old name", async () => {
|
||||
const handler: RequestHandler = (req, res) => { res.json({ eventId: req.params.eventId, q: req.query.x }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/things/:id', tag: 'T', summary: 's', access: 'public', v1: handler, map: ctx => ({ params: { eventId: ctx.params.id } }), params: z.object({ id: z.string() }) }));
|
||||
const res = await request(app).get('/api/v2/things/e1?x=2');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { eventId: 'e1', q: '2' } });
|
||||
});
|
||||
|
||||
test("a v1 error is a v2 error, with v1's code as the reason", async () => {
|
||||
const handler: RequestHandler = (_req, res) => { res.status(409).json({ message: 'Already closed.', code: 'EVENT_CLOSED' }); };
|
||||
const app = appWith(v1Route({ method: 'post', path: '/close', tag: 'T', summary: 's', access: 'public', v1: handler }));
|
||||
const res = await request(app).post('/api/v2/close').send({});
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: { code: 'CONFLICT', message: 'Already closed.', reason: 'EVENT_CLOSED' } });
|
||||
});
|
||||
|
||||
test('a thrown error and a next(err) both reach the error handler', async () => {
|
||||
const thrower: RequestHandler = async () => { throw Object.assign(new Error('Nope.'), { statusCode: 403 }); };
|
||||
const passer: RequestHandler = (_req, _res, next) => next(Object.assign(new Error('Missing.'), { statusCode: 404 }));
|
||||
const app = appWith(
|
||||
v1Route({ method: 'get', path: '/a', tag: 'T', summary: 's', access: 'public', v1: thrower }),
|
||||
v1Route({ method: 'get', path: '/b', tag: 'T', summary: 's', access: 'public', v1: passer }),
|
||||
);
|
||||
expect((await request(app).get('/api/v2/a')).body.error).toMatchObject({ code: 'FORBIDDEN', message: 'Nope.' });
|
||||
expect((await request(app).get('/api/v2/b')).body.error).toMatchObject({ code: 'NOT_FOUND', message: 'Missing.' });
|
||||
});
|
||||
|
||||
test('middleware chains run in order', async () => {
|
||||
const mark: RequestHandler = (req, _res, next) => { (req as unknown as { mark: string }).mark = 'yes'; next(); };
|
||||
const handler: RequestHandler = (req, res) => { res.json({ mark: (req as unknown as { mark: string }).mark }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/m', tag: 'T', summary: 's', access: 'public', v1: [mark, handler] }));
|
||||
expect((await request(app).get('/api/v2/m')).body).toEqual({ data: { mark: 'yes' } });
|
||||
});
|
||||
|
||||
test('a file a v1 handler pipes is sent as a download', async () => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="ticket.pdf"');
|
||||
const s = new PassThrough();
|
||||
s.pipe(res);
|
||||
s.write(Buffer.from('%PDF-1.4 '));
|
||||
s.end(Buffer.from('end'));
|
||||
};
|
||||
const app = appWith(v1Route({ method: 'get', path: '/pdf', tag: 'T', summary: 's', access: 'public', file: { contentType: 'application/pdf' }, v1: handler }));
|
||||
const res = await request(app).get('/api/v2/pdf').buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on('data', (d: Buffer) => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('ticket.pdf');
|
||||
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
|
||||
});
|
||||
|
||||
test("a v1 router is given the v1 URL; one with no matching route is a 404", async () => {
|
||||
const router = express.Router();
|
||||
router.get('/today', (req, res) => { res.json({ q: req.query.x }); });
|
||||
const app = appWith(
|
||||
v1Route({ method: 'get', path: '/dash', tag: 'T', summary: 's', access: 'public', v1: router, map: ctx => ({ url: '/today?x=' + String(ctx.req.query.x) }) }),
|
||||
v1Route({ method: 'get', path: '/nothing', tag: 'T', summary: 's', access: 'public', v1: router, map: () => ({ url: '/missing' }) }),
|
||||
);
|
||||
expect((await request(app).get('/api/v2/dash?x=7')).body).toEqual({ data: { q: '7' } });
|
||||
const res = await request(app).get('/api/v2/nothing');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
test("a file v1 sends with res.download is a download", async () => {
|
||||
const os = require("os"); const path = require("path");
|
||||
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
|
||||
require("fs").writeFileSync(file, "-- backup");
|
||||
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
|
||||
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
|
||||
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-disposition"]).toContain("backup.sql");
|
||||
expect((res.body as Buffer).toString()).toBe("-- backup");
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
+119
-36
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.8** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-11-02, after **1.52.2** and Hub **1.6.0**; 2.0.0 in progress on `release/2.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,12 +14,26 @@ 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 |
|
||||
| 1.51.9 | **Series, season passes, bundles, coupons, campuses on v2** — `src/services/series.ts`, `deals.ts`, `campuses.ts`; `reason` on v2 errors from service codes; frontend `apiV2Data()` |
|
||||
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
|
||||
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
|
||||
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
|
||||
| 1.51.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
|
||||
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
@@ -35,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` |
|
||||
@@ -51,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 |
|
||||
@@ -62,25 +72,49 @@ 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.9 – 1.51.15: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.15 gives incoming
|
||||
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
|
||||
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
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 — in progress on `release/2.0`** (WIP commit per part; tag 2.0.0 only when all of it
|
||||
is in). The parts and their state are in the roadmap's Status table:
|
||||
- ✅ **Hub 1.6.0** — the AI proxy (tagged, pushed). Real Claude calls tested 2026-10-30 through
|
||||
a local Hub with Joshua's key: fine, and a fraction of a cent each.
|
||||
- ✅ **AI plumbing** on Cross Events (`backend/src/ai/`, `/api/v2/ai/*`, Site settings → AI,
|
||||
`<AiButton>`, `useAiStatus()`; roadmap §15 "Progress").
|
||||
- **Next: the five helpers** (`backend/src/ai/helpers.ts`, their routes and buttons, an eval set,
|
||||
privacy notice text).
|
||||
- Then **v1 removed** — keep answering the v1 webhook paths and the old links in sent messages
|
||||
(`src/v2/links.ts`): providers and old emails still use them. Then the **migration guide**.
|
||||
- **Marketing site** (roadmap §17): a simple static HTML site — Cross Code as the main site,
|
||||
with a Cross Events page; room for a church app and a full church website later. Repo,
|
||||
domain and hosting to decide first.
|
||||
- Testing AI locally: run the Hub (`../events-hub`, port 5200), set Settings → AI there, give a
|
||||
test licence an AI cap, and start Cross Events with `HUB_URL=http://localhost:5200`.
|
||||
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. The model is **Claude Sonnet 5.5** for every helper
|
||||
(2026-10-30, after a Haiku/Sonnet trial), set on the Hub (Settings → AI, Hub 1.6.1); its list
|
||||
prices live in `events-hub/backend/src/utils/aiModels.js`.
|
||||
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
|
||||
|
||||
@@ -107,7 +141,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 →
|
||||
@@ -168,8 +202,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`.
|
||||
@@ -186,7 +220,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
|
||||
@@ -459,13 +494,61 @@ a test server (both done 2026-10-28).
|
||||
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
|
||||
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
|
||||
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
|
||||
payments, audit log, consent/access logs, data requests, My payments.
|
||||
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
|
||||
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
|
||||
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## v1 handlers behind v2 routes (1.51.11 –)
|
||||
|
||||
- **`v1Route({ …defineRoute options, v1: handler, map? })`** (`src/v2/viaV1.ts`) answers a v2 route
|
||||
with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with
|
||||
params/query/body replaced by `map(ctx)` — e.g. `map: ctx => ({ params: { eventId: ctx.params.id } })`
|
||||
where v1 named it `:eventId`. The same campus middleware goes in `middleware`.
|
||||
- v1 JSON → `{ data }`; a 4xx/5xx → the v2 error (v1 `code` → `reason`); a redirect →
|
||||
`{ data: { redirectUrl } }`; with `file`, whatever was sent or piped → a download.
|
||||
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
|
||||
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
|
||||
only allows declared ones.
|
||||
- **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
|
||||
in the repo); calls with a computed URL are done by hand.
|
||||
|
||||
## Managing registrations on v2 (1.51.10)
|
||||
|
||||
- **`registrationAdmin.ts` takes a `Call`** — `{ actor, params, body, query, readOnly, ip, listWhere }`
|
||||
— and returns what v1 answered, because these handlers were moved nearly as they were (about
|
||||
1,400 lines). v1 builds it with `callOf(req)`, v2 with `callFrom(req, user, parts)` in
|
||||
`resources/registrationsManage.ts`. Lists that are campus-scoped call `call.listWhere()`, which
|
||||
refuses (500) when a caller didn't give one, so nothing is listed unscoped by mistake.
|
||||
- Errors a JS helper throws without a status were 400s on v1; `as400` keeps them 400 on v2
|
||||
(TypeErrors and the like stay 500s).
|
||||
- The staff list is paged on v2 (up to 200 a page); screens that need everything use
|
||||
`fetchAllRegistrations(token, { unpaid?, userId? })`.
|
||||
|
||||
## Series, deals and campuses on v2 (1.51.9)
|
||||
|
||||
- **`apiV2Data(method, path, opts)`** (`lib/apiV2.ts`) returns `data` untyped for screens that keep
|
||||
their own record types: `await (apiV2Data("get", "/coupons", { authToken }) as Promise<Coupon[]>)`.
|
||||
TypeScript can't infer the method and path when a result type is given, so cast instead.
|
||||
- **Codes in service errors** (`fail(409, message, 'CAMPUS_HAS_EVENTS')`) reach v2 clients as
|
||||
`error.reason`, and v1 controllers still send them as `code`.
|
||||
- Answers that aren't failures (a coupon that doesn't apply) are `200` with a flag on v2.
|
||||
|
||||
## The rest of event set-up on v2 (1.51.8)
|
||||
|
||||
- **One event:** `fetchEvent(id, { authToken, nextOptions })` (`lib/api.ts`, v2 `GET /events/{id}/full`) —
|
||||
@@ -476,7 +559,7 @@ a test server (both done 2026-10-28).
|
||||
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
|
||||
`middleware`; check `req.multerError`/`req.file` in the handler).
|
||||
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
|
||||
email/WhatsApp senders (messaging, 1.51.13).
|
||||
email/WhatsApp senders — all on v2 since 1.51.14 (`v1Route`).
|
||||
|
||||
## Setting events up on v2 (1.51.7)
|
||||
|
||||
@@ -503,8 +586,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.
|
||||
|
||||
@@ -549,13 +633,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.
|
||||
@@ -566,8 +651,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.
|
||||
|
||||
|
||||
+108
-54
@@ -11,38 +11,46 @@ 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** shipped.
|
||||
**Next: 1.51.9 – 1.51.15 (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). **2.0.0 is in progress** on the `release/2.0` branch, tagged only
|
||||
when all of it is in (decided 2026-10-30):
|
||||
|
||||
| # | Part | Where | State |
|
||||
|---|------|-------|-------|
|
||||
| 1 | AI proxy: key, metering in rand, caps, markup, usage views; model list with prices (Sonnet 5.5 default) | Hub 1.6.0 – 1.6.1 (`events-hub`) | ✅ tagged; real Claude calls tested 2026-10-30 |
|
||||
| 2 | AI plumbing: `/api/v2/ai/status`, usage, Site settings → AI, `<AiButton>`, `useAiStatus()`, `AiCall` log, Anthropic operator entry | `release/2.0` | ✅ committed (WIP) |
|
||||
| 3 | The five helpers (§15) with their buttons, an eval set, privacy notice text | `release/2.0` | next |
|
||||
| 4 | v1 removed (webhook paths and old message links keep working) | `release/2.0` | to do |
|
||||
| 5 | Migration guide, docs | `release/2.0` | to do |
|
||||
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | its own repo | to do |
|
||||
|
||||
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:**
|
||||
|
||||
@@ -71,8 +79,9 @@ ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Suggested build order](#suggested-build-order)
|
||||
18. [Decisions](#decisions-2026-10-22)
|
||||
17. [Marketing site (Cross Code)](#17-marketing-site-cross-code)
|
||||
18. [Suggested build order](#suggested-build-order)
|
||||
19. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -830,13 +839,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -863,13 +872,18 @@ never saves, sends or publishes by itself.
|
||||
|
||||
### Provider and model
|
||||
|
||||
- **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
|
||||
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
|
||||
prompt-cached, which makes repeat calls cheaper still.
|
||||
- **One provider: Claude (Anthropic), model Claude Sonnet 5.5** for every helper (decided
|
||||
2026-10-30) — $2 per million input tokens, $10 per million output (October 2026 list prices).
|
||||
First planned as Haiku 5.5 ($0.10 / $0.50) for cost; a trial of all five helpers on both
|
||||
(2026-10-30) found Haiku wrong on a report figure, over-counting feedback themes and misspelling
|
||||
words in messages, while Sonnet was right on all of them. One model for all helpers, no
|
||||
per-helper choice. The model is a setting on the Hub (Settings → AI: a list with prices filled
|
||||
in, Hub 1.6.1), so changing it later needs no Cross Events release.
|
||||
- **Measured cost** (trial, charged at R17/$ and 5% markup): R0.02 – R0.31 per use with Sonnet —
|
||||
a WhatsApp message about R0.02, a report summary R0.17 – R0.22, a full event draft with costs
|
||||
R0.31. A typical church month (10 event drafts, 20 messages, 20 text improvements, 10 reports, 4
|
||||
feedback summaries) comes to about R8.33, so a R50 allowance covers it about 6 times. Fixed
|
||||
instructions are prompt-cached, which makes repeat calls cheaper still.
|
||||
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
|
||||
onto the event form and is validated before it's shown.
|
||||
|
||||
@@ -942,6 +956,23 @@ an install (one leaked install would leak it for everyone), so:
|
||||
looks like) to check quality when changing the prompt or model.
|
||||
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
### Progress (2026-10-30)
|
||||
|
||||
- **Done — Hub 1.6.0:** `POST /api/installs/ai/status` and `/api/installs/ai/complete` (protocol §3b);
|
||||
the worst case of each call held against the cap in a locked transaction; Settings → AI (key,
|
||||
model, prices, exchange rate, default markup, test button, use per site); markup override per
|
||||
licence; AI use (real cost, charged, profit) on the install page. Tested with real calls: a plain
|
||||
message (118 tokens in, 70 out, ~1.3 s) and a JSON-schema event draft (483 in, 95 out, ~3 s), each
|
||||
charged a fraction of a cent (R0.0008 – R0.0017 at R17/$ and 5% markup).
|
||||
- **Done — Cross Events, `release/2.0`:** `backend/src/ai/` (status from the licence token, then
|
||||
the Hub, cached a minute; per-helper switches; per-person daily limit; `AiCall` log; 80% email),
|
||||
`/api/v2/ai/status`, `/ai/usage`, `/ai/settings`, the `UNAVAILABLE` (503) error code,
|
||||
`frontend/src/lib/ai.ts` (`runAi`, which posts to `/api/v2/ai/<helper-with-dashes>`),
|
||||
`useAiStatus()`, `<AiButton>` / `<AiNotice>`, Site settings → AI.
|
||||
- **Next:** the helpers in `backend/src/ai/helpers.ts` (prompt, schema, `maxTokens`, and what each
|
||||
sends — no personal information), their `/api/v2/ai/<helper>` routes, the buttons on the five
|
||||
screens, the eval set, and the privacy notice text (a `BUILT_IN_UPDATES` entry).
|
||||
|
||||
---
|
||||
|
||||
## 16. AI: later stages
|
||||
@@ -995,13 +1026,32 @@ income justify the hardware:
|
||||
|
||||
---
|
||||
|
||||
## 17. Marketing site (Cross Code)
|
||||
|
||||
Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not part of the app.
|
||||
|
||||
- **Cross Code** (Joshua's company) is the main site: who we are, what we make, contact.
|
||||
- **Cross Events** has its own page: what it does for a church (registrations and payments,
|
||||
multi-day events, kids check-in, volunteer rosters, series and season passes, campuses, POPIA
|
||||
toolkit, the AI helpers), how it's set up (one site and database per church, updates on request,
|
||||
support through the Hub), and how to get in touch for a licence.
|
||||
- **Cross Events is the only product on sale for now.** The site is laid out so more products can
|
||||
be added later — planned: a **church app** and a **full church website** — without a redesign
|
||||
(shown as "coming later" at most, until they exist).
|
||||
- Plain HTML and CSS (no build step, no framework), fast on a phone, no tracking cookies.
|
||||
- **To decide before building:** the repository (its own, e.g. `joshua/crosscode-site`, not this
|
||||
one), the domain(s) (e.g. crosscode.co.za, with Cross Events at /cross-events or its own domain),
|
||||
hosting, pricing shown or "contact us", and the contact route (email address or a form).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
@@ -1011,8 +1061,8 @@ 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.8 ✅, 1.51.9 – 1.51.15 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.11 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.12 kids check-in and volunteer rosters; 1.51.13 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.14 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.15 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 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 |
|
||||
| 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, docs, and the marketing site (§17: a static Cross Code site with a Cross Events page). Built on `release/2.0` in parts; Hub 1.6.0 (AI proxy) ✅ and the AI plumbing ✅ are in | 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/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -1047,10 +1097,12 @@ feature.
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude**. Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24). Model: **Sonnet 5.5 for every helper** (2026-10-30, after the Haiku/Sonnet trial; was Haiku 5.5 for cost), chosen on the Hub |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.15, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.15); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.16, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.16); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
|
||||
| — | Building 2.0 | On a `release/2.0` branch with a WIP commit per part, tagged 2.0.0 only when everything is in; the Hub's AI proxy ships first as its own release (Hub 1.6.0) (2026-10-30) |
|
||||
| — | Marketing site | Part of 2.0: a simple static HTML site — Cross Code as the main site, with a page for Cross Events, the only product on sale for now. Laid out to add a church app and a full church website later. Repo, domain and hosting still to decide (2026-10-30) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
@@ -1072,4 +1124,6 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing for now.
|
||||
- **Marketing site (§17):** repository, domain(s), hosting, whether prices are shown, and the
|
||||
contact route.
|
||||
- **Password hashing upgrade** (§14): in 2.0 or later.
|
||||
|
||||
+60
-6
@@ -178,6 +178,65 @@ X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
|
||||
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
|
||||
|
||||
## 3b. AI (Cross Events 2.0, Hub 1.6.0)
|
||||
|
||||
Installs never hold the Claude API key. Each AI helper call goes through the Hub, which adds the
|
||||
key, checks the licence's monthly cap, calls Claude and meters the call. Both requests are signed
|
||||
exactly like the heartbeat. The install keeps its prompts; the Hub stores **no prompt or response
|
||||
text** — only the install, helper name, tokens, cost and time.
|
||||
|
||||
### Status
|
||||
|
||||
```
|
||||
POST /api/installs/ai/status
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1 }
|
||||
→ 200 { "enabled": true, "capReached": false, "capCents": 5000, "usedCents": 812.4,
|
||||
"usedPercent": 16, "renewsOn": "2026-12-01" }
|
||||
```
|
||||
|
||||
- **`enabled: false`** — hide the AI buttons everywhere: the licence's cap is R0, the licence isn't
|
||||
active (suspended, revoked, or past its grace days), or the Hub's AI isn't set up.
|
||||
- **`capReached: true`** — show the buttons **disabled** ("This month's AI allowance is used up — it
|
||||
renews on 1 December"). Reached means there isn't room left for even a small call.
|
||||
- Amounts are cents (rand) as **charged** to the church, with fractions (a call costs a fraction of a
|
||||
cent). The month is South African time; `renewsOn` is the 1st of the next month.
|
||||
|
||||
### A helper call
|
||||
|
||||
```
|
||||
POST /api/installs/ai/complete
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"helper": "draft_event", // lower-case name, for usage reports
|
||||
"system": "…", // the helper's fixed instructions (cached by Claude)
|
||||
"input": "…", // this request's text
|
||||
"schema": { … }, // optional JSON schema: the reply is JSON in that shape
|
||||
"maxTokens": 2000, // 1–8000
|
||||
"effort": "low" // optional: low (default) | medium | high
|
||||
}
|
||||
→ 200 { "output": "…" | { … }, // text, or the parsed JSON when a schema was given
|
||||
"usage": { "inputTokens": 1840, "outputTokens": 420 },
|
||||
"chargedCents": 0.31,
|
||||
"status": { …as above… } }
|
||||
```
|
||||
|
||||
- **Errors** are `{ code, message, status }` — `status` is the new AI status, so buttons can disable
|
||||
straight after the call that used up the allowance:
|
||||
- `402 AI_CAP_REACHED` — this call could pass the cap (checked before calling Claude).
|
||||
- `403 AI_OFF` (cap R0) · `403 LICENCE_INACTIVE` · `503 AI_NOT_CONFIGURED` (Hub not set up).
|
||||
- `422 AI_REFUSED` — Claude declined. `502 AI_INCOMPLETE` — the answer hit `maxTokens`.
|
||||
`502 AI_FAILED` — not usable (e.g. JSON in the wrong shape). These still cost what was used.
|
||||
- `503 AI_BUSY` / `503 AI_UNAVAILABLE` — Claude unreachable or rate-limited; nothing is charged.
|
||||
- `400 BAD_REQUEST`, `413 AI_INPUT_TOO_LARGE` (about 90 000 tokens), and the §3 errors.
|
||||
- **Metering:** real cost = tokens × the model's price × the exchange rate set in the Hub; charged =
|
||||
real × (1 + markup). The cap counts charged rand. Before calling Claude the Hub holds the worst
|
||||
case (an over-estimate of the prompt, plus `maxTokens`) against the cap in one transaction per
|
||||
install, so calls at the same moment can't pass it; afterwards the hold becomes the real amount.
|
||||
- Rate-limited to 60 a minute per IP (against runaway loops; money is limited by the cap).
|
||||
- A Hub older than 1.6.0 answers `404 NOT_FOUND`: the install treats that as "AI off".
|
||||
|
||||
## 4. Licence token
|
||||
|
||||
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||||
@@ -195,7 +254,7 @@ Ed25519 signature of the ASCII string `hel1.<payload>`.
|
||||
"graceDays": 14,
|
||||
"issuedAt": "2026-10-24T08:15:00.000Z",
|
||||
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
|
||||
"limits": { "aiMonthlyCapCents": 0 }
|
||||
"limits": { "aiMonthlyCapCents": 0 } // the AI cap (§3b); 0 = AI off
|
||||
}
|
||||
```
|
||||
|
||||
@@ -221,8 +280,3 @@ grace starts). Blocking the Hub can't keep a suspended licence active for more t
|
||||
fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they
|
||||
don't know.
|
||||
- The Hub accepts protocol `N` and `N-1`; anything else is `400 UNSUPPORTED_PROTOCOL`.
|
||||
|
||||
## 6. Later (not in v1)
|
||||
|
||||
- **AI proxy** (Cross Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds
|
||||
the Claude API key, meters each call in rand and enforces `limits.aiMonthlyCapCents`.
|
||||
|
||||
+195
-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 }) => {
|
||||
@@ -148,12 +158,195 @@ test.describe("API v2", () => {
|
||||
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
|
||||
});
|
||||
|
||||
test("series, coupon codes, bundle quotes and campuses", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
|
||||
const made = await request.post(`${API}/api/v2/series`, { headers: sup, data: { name: "E2E v2 Series", description: "Made through v2" } });
|
||||
expect(made.status()).toBe(201);
|
||||
const series = (await made.json()).data;
|
||||
const renamed = (await (await request.patch(`${API}/api/v2/series/${series.id}`, { headers: sup, data: { name: "E2E v2 Series (renamed)" } })).json()).data;
|
||||
expect(renamed.name).toBe("E2E v2 Series (renamed)");
|
||||
const page = (await (await request.get(`${API}/api/v2/series/by-slug/${series.slug}`, { headers: user })).json()).data;
|
||||
expect(page).toMatchObject({ id: series.id, following: false, upcoming: [], past: [] });
|
||||
expect((await (await request.put(`${API}/api/v2/series/${series.id}/follow`, { headers: user })).json()).data).toEqual({ following: true });
|
||||
|
||||
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
const opt = ev.eventOptions[0];
|
||||
const code = await request.post(`${API}/api/v2/coupons`, { headers: sup, data: { code: "E2EV2TEN", type: "percent", value: 10, eventIds: [ev.id] } });
|
||||
expect(code.status()).toBe(201);
|
||||
const basket = { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }] };
|
||||
const ok = (await (await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "E2EV2TEN" } })).json()).data;
|
||||
expect(ok).toMatchObject({ valid: true, code: "E2EV2TEN" });
|
||||
const bad = await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "NO-SUCH-CODE" } });
|
||||
expect(bad.status()).toBe(200);
|
||||
expect((await bad.json()).data.valid).toBe(false);
|
||||
|
||||
const quote = (await (await request.post(`${API}/api/v2/bundles/quote`, { data: basket })).json()).data;
|
||||
expect(quote).toMatchObject({ bundleDiscount: 0 });
|
||||
expect(quote.total).toBe(quote.subtotal);
|
||||
|
||||
const campuses = (await (await request.get(`${API}/api/v2/campuses`)).json()).data;
|
||||
// On or off depending on which tests ran first: when off, the list is empty.
|
||||
if (campuses.enabled) expect(campuses.campuses.map((c: { slug: string }) => c.slug)).toEqual(expect.arrayContaining([seed().campuses.north.slug]));
|
||||
else expect(campuses.campuses).toEqual([]);
|
||||
expect((await request.get(`${API}/api/v2/campuses/admin`, { headers: sup })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("staff register someone at the door and manage the registration", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
const opt = ev.eventOptions[0];
|
||||
|
||||
const made = await request.post(`${API}/api/v2/registrations/manual`, {
|
||||
headers: sup,
|
||||
data: { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }], user: { name: "E2E Door Guest", email: "e2e-v2-door@example.com" }, skipActivationNotice: true },
|
||||
});
|
||||
expect(made.status()).toBe(201);
|
||||
const reg = (await made.json()).data;
|
||||
expect(reg).toMatchObject({ eventId: ev.id, status: expect.any(String) });
|
||||
|
||||
const list = (await (await request.get(`${API}/api/v2/events/${ev.id}/registrations?search=e2e-v2-door`, { headers: sup })).json()).data;
|
||||
expect(list.map((r: { id: string }) => r.id)).toContain(reg.id);
|
||||
|
||||
const full = (await (await request.get(`${API}/api/v2/registrations/${reg.id}/full`, { headers: sup })).json()).data;
|
||||
expect(full.registrationOptions[0]).toMatchObject({ eventOptionId: opt.id, quantity: 1 });
|
||||
|
||||
const bad = await request.patch(`${API}/api/v2/registrations/${reg.id}/status`, { headers: sup, data: { status: "refunded" } });
|
||||
expect(bad.status()).toBe(400);
|
||||
expect((await bad.json()).error.code).toBe("VALIDATION_FAILED");
|
||||
|
||||
const page = await (await request.get(`${API}/api/v2/registrations?limit=2`, { headers: sup })).json();
|
||||
expect(page.meta).toMatchObject({ page: 1, limit: 2 });
|
||||
expect(page.data.length).toBeLessThanOrEqual(2);
|
||||
|
||||
// Tidy up: cancelled registrations don't count towards anything.
|
||||
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: admin });
|
||||
expect((await cancelled.json()).data.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
test("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const providers = await request.get(`${API}/api/v2/payments/providers`);
|
||||
expect(providers.status()).toBe(200);
|
||||
expect(await providers.json()).toHaveProperty("data");
|
||||
|
||||
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
|
||||
expect(cash.status()).toBe(200);
|
||||
expect((await cash.json()).data).toBeTruthy();
|
||||
|
||||
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
|
||||
expect(mine.status()).toBe(200);
|
||||
|
||||
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
|
||||
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
|
||||
|
||||
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
|
||||
const res = await request.get(`${API}${path}`);
|
||||
expect(res.status(), path).toBeGreaterThanOrEqual(400);
|
||||
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
}
|
||||
});
|
||||
|
||||
test("tickets and attendance: mine, the register, a spreadsheet download, links that don't exist", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const mine = await request.get(`${API}/api/v2/me/tickets`, { headers: user });
|
||||
expect(mine.status()).toBe(200);
|
||||
expect(Array.isArray((await mine.json()).data)).toBe(true);
|
||||
|
||||
const reg = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance`, { headers: staff });
|
||||
expect(reg.status()).toBe(200);
|
||||
|
||||
const xlsx = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance/export?format=xlsx`, { headers: staff });
|
||||
expect(xlsx.status()).toBe(200);
|
||||
expect(xlsx.headers()["content-type"]).toContain("spreadsheetml");
|
||||
expect(xlsx.headers()["content-disposition"]).toContain(".xlsx");
|
||||
expect((await xlsx.body()).subarray(0, 2).toString()).toBe("PK");
|
||||
|
||||
// A code that isn't a certificate is an answer, not an error.
|
||||
expect((await (await request.get(`${API}/api/v2/certificates/verify/NO-SUCH-CODE`)).json()).data).toEqual({ valid: false });
|
||||
const transfer = await request.get(`${API}/api/v2/ticket-transfers/not-a-real-token`);
|
||||
expect(transfer.status()).toBeGreaterThanOrEqual(400);
|
||||
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
});
|
||||
|
||||
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
|
||||
const res = await request.get(`${API}${path}`, { headers });
|
||||
expect(res.status(), path).toBe(200);
|
||||
expect(await res.json(), path).toHaveProperty("data");
|
||||
}
|
||||
// Staff lists aren't for everyone.
|
||||
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
|
||||
expect(Array.isArray(listing)).toBe(true);
|
||||
expect(listing[0]).toHaveProperty("eventOptions");
|
||||
|
||||
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
|
||||
const res = await request.get(`${API}${path}`, { headers: staff });
|
||||
expect(res.status(), path).toBe(200);
|
||||
}
|
||||
|
||||
// Saved filters run the v1 router: save one, list it, delete it.
|
||||
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
|
||||
expect(saved.status()).toBe(200);
|
||||
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
|
||||
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
|
||||
expect(mine).toBeTruthy();
|
||||
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
|
||||
|
||||
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
|
||||
expect(users).toMatchObject({ page: 1, limit: 2 });
|
||||
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
|
||||
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
|
||||
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
|
||||
expect(unknown.status()).toBe(200);
|
||||
expect(await unknown.json()).toEqual(await known.json());
|
||||
// Nor does sign-in: an unknown email is refused like a wrong password.
|
||||
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
|
||||
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
|
||||
expect(noAccount.status()).toBe(wrongPassword.status());
|
||||
expect(await noAccount.json()).toEqual(await wrongPassword.json());
|
||||
});
|
||||
|
||||
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
|
||||
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
|
||||
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
|
||||
|
||||
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
|
||||
expect(exported.status()).toBe(200);
|
||||
expect(exported.headers()["content-disposition"]).toContain("my-data-");
|
||||
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
|
||||
|
||||
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
|
||||
const res = await request.get(`${API}${path}`, { headers: admin });
|
||||
expect(res.status(), path).toBe(200);
|
||||
}
|
||||
// Admin areas aren't for everyone.
|
||||
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
|
||||
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
|
||||
// Sections start collapsed (there are hundreds of endpoints): open Events.
|
||||
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
|
||||
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
|
||||
// The token isn't left in the address bar.
|
||||
expect(page.url()).not.toContain("token=");
|
||||
|
||||
@@ -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.8",
|
||||
"version": "1.52.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.52.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.52.3",
|
||||
"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);
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { CalendarX, MapPin, Mail, Phone, User, ArrowLeft } from "lucide-react";
|
||||
import type { Campus } from "@/hooks/useCampuses";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -16,7 +17,7 @@ type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?:
|
||||
|
||||
async function loadCampus(slug: string): Promise<Campus | null> {
|
||||
try {
|
||||
return await apiFetch<Campus>(`/api/campuses/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
return await (apiV2Data("get", "/campuses/by-slug/{slug}", { params: { slug }, nextOptions: { next: { revalidate } } }) as Promise<Campus>);
|
||||
} catch (e) {
|
||||
// 404: no such campus, archived, or campuses are off.
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
@@ -41,7 +42,7 @@ export default async function CampusPage({ params }: { params: Promise<{ slug: s
|
||||
const campus = await loadCampus(slug);
|
||||
if (!campus) notFound();
|
||||
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || [])
|
||||
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Metadata } from "next";
|
||||
import { BadgeCheck, ShieldX } from "lucide-react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function VerifyCertificatePage({ params }: { params: Promis
|
||||
const { code } = await params;
|
||||
let result: Verification = { valid: false };
|
||||
try {
|
||||
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
|
||||
result = await (apiV2Data("get", "/certificates/verify/{code}", { params: { code: code }, nextOptions: { cache: "no-store" } }) as Promise<Verification>);
|
||||
} catch {
|
||||
result = { valid: false };
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
@@ -39,7 +40,7 @@ export default function ConsentPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/consent/{token}", { params: { token: token } }) as Promise<ConsentInfo>)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
@@ -50,8 +51,8 @@ export default function ConsentPage() {
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/consent/{token}", {
|
||||
params: { token: token },
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
@@ -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);
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -42,7 +43,7 @@ export default function EventCashupPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
(apiV2Data("get", "/events/{id}/cashup", { params: { id: eventId }, authToken: token }) as Promise<EventFinancials>),
|
||||
fetchEvent<Event>(eventId, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
@@ -309,7 +310,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
(apiV2Data("get", "/events/{id}/cashup/cash-by-user", { params: { id: eventId }, authToken: token }) as Promise<AccountabilityResponse>)
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -458,8 +459,10 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
const save = async () => {
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
await apiV2Data("put", "/events/{id}/cashup/person-cash/{userId}", {
|
||||
params: { id: eventId, userId: userId },
|
||||
authToken: token,
|
||||
body: { denominations: lines, notes: notes || null },
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -833,7 +836,7 @@ function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod;
|
||||
if (!r.lineId || !mode) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
await apiV2Data("post", "/cashup-lines/{id}/pending", { params: { id: r.lineId }, authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
setMode(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
@@ -972,7 +975,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
await apiV2Data("put", "/events/{id}/cashup/draft", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
@@ -992,7 +995,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1006,7 +1009,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1020,7 +1023,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/reopen", { params: { id: eventId }, authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -12,6 +12,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -80,7 +81,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token);
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
@@ -124,7 +125,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const res = await (apiV2Data("get", "/form-responses", { query: { registrationId: id }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
@@ -140,7 +141,7 @@ export default function AdminRegistrationsPage() {
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
await apiV2Data("post", "/registrations/{id}/cancel", { params: { id: reg.id }, authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
@@ -152,7 +153,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
await apiV2Data("patch", "/registrations/{id}/status", { params: { id: reg.id }, authToken: token, body: { status: status as "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled" } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,10 +4,11 @@ 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";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook, Sparkles } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
@@ -15,6 +16,7 @@ import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { LicenceTab } from "@/components/admin/LicenceTab";
|
||||
import { WebhooksTab } from "@/components/admin/WebhooksTab";
|
||||
import { AiTab } from "@/components/admin/AiTab";
|
||||
import { CampusesTab } from "@/components/admin/CampusesTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
@@ -22,7 +24,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence" | "ai";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -37,6 +39,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
{ id: "licence", label: "Licence", icon: KeyRound },
|
||||
{ id: "ai", label: "AI", icon: Sparkles },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -164,7 +167,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 +215,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 +254,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 +286,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 +328,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 +354,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(),
|
||||
@@ -717,6 +719,7 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "webhooks" && <WebhooksTab active={activeTab === "webhooks"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
{activeTab === "ai" && <AiTab active={activeTab === "ai"} />}
|
||||
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
|
||||
</div>
|
||||
|
||||
@@ -884,7 +887,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 +901,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 +924,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 +948,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 +975,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 +993,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 +1007,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 +1028,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 +1262,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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user