Compare commits
69
Commits
v1.49.1
...
release/2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9547793f25 | ||
|
|
99efe7a7d0 | ||
|
|
3c983070a1 | ||
|
|
c63c3a3604 | ||
|
|
67d0a83ab8 | ||
|
|
2bcc9ac651 | ||
|
|
b2906011fd | ||
|
|
70850df4f6 | ||
|
|
cbebcdbd48 | ||
|
|
849998acb3 | ||
|
|
c5fe17a1ef | ||
|
|
6164fdcb3b | ||
|
|
1510c2a057 | ||
|
|
fa4191d18c | ||
|
|
e0dae5647f | ||
|
|
4109cb4258 | ||
|
|
ce9734090c | ||
|
|
9012ec64b6 | ||
|
|
7c252e0cb3 | ||
|
|
735a76cc74 | ||
|
|
e2e881be85 | ||
|
|
4ca896130b | ||
|
|
d2d8cb1934 | ||
|
|
ad40618267 | ||
|
|
fc63454439 | ||
|
|
8f6f90a151 | ||
|
|
dacd49652b | ||
|
|
4c066e67d9 | ||
|
|
0ffbb13a1f | ||
|
|
e9d1b1fb24 | ||
|
|
98173c1380 | ||
|
|
6297613d0d | ||
|
|
5efb4cd6dd | ||
|
|
e40803cbbc |
+520
@@ -7,6 +7,526 @@ 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.
|
||||
|
||||
## [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
|
||||
|
||||
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
|
||||
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
|
||||
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
|
||||
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
|
||||
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
|
||||
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
|
||||
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
|
||||
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
|
||||
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
|
||||
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
|
||||
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
|
||||
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
|
||||
are described as multipart in the OpenAPI document.
|
||||
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
|
||||
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
|
||||
|
||||
### Changed
|
||||
|
||||
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
|
||||
path; old manifests can still be imported with `/attachments/sync`.
|
||||
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
|
||||
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
|
||||
(to 1.51.15).
|
||||
|
||||
## [1.51.7] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — setting events up:** `GET /events/manage` (every event staff may see, hidden ones too;
|
||||
`past`, `inactive`, `closed`; campus-scoped), `POST /events`, `PATCH /events/{id}` (only what's sent
|
||||
changes; `sessionChanges` when sessions were sent), `DELETE /events/{id}` (admins; deactivates),
|
||||
`POST /events/{id}/copy-from`, and `GET`/`PUT /events/{id}/notify-recipients`. Event records are
|
||||
typed for their core fields and pass the rest through as stored (`z.looseObject`).
|
||||
- **`src/services/events.ts`:** create, update, deactivate, the staff list, notify recipients and
|
||||
copy-from moved out of `eventController.js` (2,217 → 1,717 lines), with the schedule helpers
|
||||
(`withSchedule`, `scheduleData`, `saveSchedule`). v1 uses it too. The pre-migration fallbacks
|
||||
(retrying without `registrationDeadline`, `goLiveAt`…) are gone — every install has those columns.
|
||||
Creating an event now also returns its ticket options.
|
||||
- Frontend: `fetchManagedEvents()` (`lib/api.ts`) — every event picker, the kiosk, reports and the
|
||||
set-up screens load events through v2; the event wizard creates, saves, copies and sets
|
||||
notification recipients through v2. v1 deprecation links for these endpoints.
|
||||
|
||||
### Changed
|
||||
|
||||
- **pg-boss 11** (servers are on Node 24). It can't upgrade pg-boss 10's tables, so the job queue
|
||||
uses a new schema, `pgboss_v11`; schedules are recreated at start, and the admin page's
|
||||
"recent runs" start again from this release. The old `pgboss` schema is left as it was (it can
|
||||
be dropped: `DROP SCHEMA pgboss CASCADE;`). pg-boss 11 brings cron-parser 5, so the last
|
||||
cron-parser 4 deprecation is gone.
|
||||
- Roadmap: the rest of the events area (event details for staff, ticket options and variants,
|
||||
attachments, sections, costs, series, season passes, bundles, coupons, campuses, calendar file,
|
||||
alias) is 1.51.8; the later areas move up one (to 1.51.14).
|
||||
|
||||
## [1.51.6] - 2026-10-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Command palette (Ctrl+K) was missing things.** It listed 9 of the 19 reports — now every
|
||||
report (built from the report catalogue, so new ones appear by themselves), plus Dashboard,
|
||||
Reports, Ticket options and My groups, and your own pages (My events & tickets, My payments,
|
||||
My privacy, My kids, My serving, My attendee forms, Make a donation). Attendee forms now opens
|
||||
for staff and supervisors too (the page allows them; the palette had it as admin-only). An e2e
|
||||
test fails when a dashboard page is neither in the palette nor deliberately left out.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Excel exports use ExcelTS** (`@cj-tech-master/excelts` 10) instead of exceljs, which is
|
||||
unmaintained: same API, no dependencies — the backend has 87 fewer packages and none of
|
||||
exceljs's deprecated ones (fstream, glob 7, inflight, lodash.isequal, rimraf 2). New
|
||||
`e2e/excel.spec.ts` opens a real report and attendance export. ExcelTS itself now says it's no
|
||||
longer developed (its successor, documonster, is pre-1.0); existing versions keep working.
|
||||
- The backend needs **Node 22 or newer** (`engines`); the servers run Node 24 LTS.
|
||||
- Roadmap: incoming webhooks and links in sent messages get v2 URLs too (1.51.13), with the v1 paths
|
||||
still answering; the area releases are now 1.51.7 – 1.51.13.
|
||||
|
||||
## [1.51.5] - 2026-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Next.js 16** (from 15.5) and **React 19.3**. Builds and dev use Turbopack (Next 16's default);
|
||||
`tsconfig.json` `jsx` is `react-jsx` (required by Next 16); `images.domains` → `remotePatterns`.
|
||||
- **ESLint 10** (ESLint 9 is out of support) with Next 16's own flat configs
|
||||
(`eslint-config-next/core-web-vitals` and `/typescript`, no more FlatCompat). `npm run lint` is
|
||||
`eslint src` (`next lint` was removed). Next 16's React Compiler-era rules
|
||||
(`react-hooks/set-state-in-effect`, `purity`, `immutability`, `static-components`, `refs`) are
|
||||
warnings for now — 181 places, mostly loading data in `useEffect`.
|
||||
- Removed the empty `middleware.ts` (a pass-through that did nothing; Next 16 renames middleware to
|
||||
"proxy"), which also drops its 117 kB bundle.
|
||||
|
||||
## [1.51.4] - 2026-10-29
|
||||
|
||||
### Security
|
||||
|
||||
- **Next.js 15.5.28** (critical: remote code execution through the image optimiser with SVGs, and a
|
||||
Server Actions denial of service), **@sentry/nextjs 10.78** (critical: middleware bypass),
|
||||
**zod 4.6.7** (high: memory exhaustion on oversized collections; backend and frontend),
|
||||
**multer 2.5.0** (high: denial of service from a pending file filter), **nodemailer 10.3.0**
|
||||
(moderate: three address-parsing flaws that could add recipients), fast-uri 3.1.9 and
|
||||
ip-address 10.7.5 (moderate). Minimum versions raised in package.json so a fresh install can't
|
||||
resolve an older one. `npm audit`: frontend and root clean; the backend's 6 remaining reports are
|
||||
low and dev-only (jest → deepmerge), with no real fix (npm suggests downgrading Jest to 25).
|
||||
|
||||
### Changed
|
||||
|
||||
- **cron-parser 5** (v4 is unmaintained): `CronExpressionParser.parse` in `src/jobs/index.js`.
|
||||
- **Node 24 LTS** for new installs (`scripts/install.sh`); Node 20 reached end of life in April 2026.
|
||||
`deploy.sh` warns on servers older than Node 22; DEPLOYMENT.md has "Upgrading Node".
|
||||
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 – 1.51.11).
|
||||
|
||||
### Known
|
||||
|
||||
- Still deprecated, with reasons: exceljs (unmaintained; brings fstream, glob 7, inflight,
|
||||
lodash.isequal, rimraf 2) — replacing it with ExcelTS waits for approval; ESLint 9 (out of
|
||||
support) — ESLint 10 needs Next.js 16 (1.51.5); @opentelemetry/sdk-trace-base — pulled in by the
|
||||
latest @sentry/nextjs, for Sentry to update; glob 10 — inside Jest (dev only).
|
||||
|
||||
## [1.51.3] - 2026-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Express 5** (from 4). Kept Express 4 behaviour where it changed: the `qs` ("extended") query
|
||||
parser, and `req.body` as `{}` when a request has no JSON body. `@types/express` 5.
|
||||
- **The ticket scanner and at-the-door check-in use API v2** (`/api/v2/checkin/{qrCode}`). The v2
|
||||
look-up now gives `attendee`, `bookedBy` and `ticketOptionId` (for section checks).
|
||||
- **Shared middleware answers in the v2 shape on /api/v2:** rate limits, the impersonation and
|
||||
licence read-only guards, the auth checks and campus refusals write v1's `{ message, code }`;
|
||||
on v2 that becomes `{ error: { code, message, reason } }` (`reason` = the specific old code, e.g.
|
||||
`TWO_FACTOR_REQUIRED`, `LICENCE_READ_ONLY`). Read-only answers have the v2 code `READ_ONLY` (403).
|
||||
The frontend's `apiFetch` treats `reason` like v1's `code`, so the 2FA and impersonation
|
||||
handling works on v2 calls too.
|
||||
|
||||
### Added
|
||||
|
||||
- **v1 deprecation headers:** v1 endpoints that have a v2 replacement answer with
|
||||
`Deprecation: @<date>` (RFC 9745) and `Link: <…>; rel="successor-version"` (`src/v2/deprecation.ts`).
|
||||
The rest of v1 has no replacement yet, so no header.
|
||||
|
||||
## [1.51.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 accounts:** `POST /auth/signup` (needs `acceptTerms`), `POST /auth/login` (answers
|
||||
`signed_in` with a token, or `code_required` for two-factor) and `POST /auth/login/code`, both
|
||||
behind the login rate limit; `PATCH /me` (only what is sent changes; a new password needs the
|
||||
current one) and `GET /me` now with `notificationPreference` and `homeCampusId`.
|
||||
- **API v2 check-in:** `GET /checkin/{qrCode}` (look up: holder, what's left, session, consent)
|
||||
and `POST /checkin/{qrCode}` (check in everyone left or `people`). Every normal result is a 200
|
||||
with an `outcome` — `checked_in`, `already_used`, `consent_required`, `wrong_event`,
|
||||
`wrong_section`, `section_not_found`, `no_session`, `locked`, `transferred`. Staff only, with the
|
||||
same campus rule as v1.
|
||||
- **Services:** sign up, log in, the 2FA step and profile changes moved from `userController.js`
|
||||
(~430 lines shorter) to `src/services/auth.ts`; scan preview and scanning from
|
||||
`ticketController.js` to `src/services/checkin.ts`. v1 answers exactly as before (`ServiceError`
|
||||
can carry the old `v1Status`, e.g. 400 for a wrong password, which is 401 in v2).
|
||||
- v2 routes can take extra middleware (`middleware: [loginLimiter]`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Logging in (and the 2FA code step), signing up, and changing your details or password use API v2.
|
||||
The scanner stays on v1 until 1.51.3.
|
||||
|
||||
## [1.51.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 registrations and payments:** `POST /registrations` (signed in, guest for free
|
||||
events, or a waitlist link), `GET /registrations/{id}`, `POST /registrations/{id}/cancel` and
|
||||
`POST /registrations/{id}/checkout` (online payment, with an optional donation; answers
|
||||
`priceUpdated` with the new balance when an early-bird price ran out). Owners only (admins too);
|
||||
someone else's registration is `NOT_FOUND`.
|
||||
- **Services in TypeScript:** making and cancelling registrations moved from
|
||||
`registrationController.js` to `src/services/registrations.ts`, and paying a registration
|
||||
online from `paymentController.js` to `src/services/payments.ts`. v1 and v2 run the same code;
|
||||
`ServiceError` carries the HTTP status. `registrationController.js` is ~560 lines shorter.
|
||||
- **Typed frontend calls:** `npm run api:types` (repo root) writes the OpenAPI document to
|
||||
`frontend/src/lib/api-v2/openapi.json` and generates `schema.d.ts` from it (openapi-typescript);
|
||||
`apiV2(method, path, { params, query, body, authToken })` in `frontend/src/lib/apiV2.ts` checks
|
||||
the path, parameters, body and answer. A backend test fails while the saved document is stale.
|
||||
|
||||
### Changed
|
||||
|
||||
- The register page, "Pay now" (registration success page and My Events) and cancelling on My
|
||||
Events use API v2. `apiFetch` reads v2 errors (`error.message`, with `code`/`fields` in `data`).
|
||||
|
||||
## [1.51.0] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod
|
||||
schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what
|
||||
the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin)
|
||||
shown at **`/docs/v2`** (Swagger UI, served locally). One error shape
|
||||
`{ error: { code, message, fields? } }` with stable codes, and one list shape
|
||||
`{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`,
|
||||
`GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged.
|
||||
- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` →
|
||||
`dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed
|
||||
doors into JS modules in `src/v2/legacy.ts`.
|
||||
- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a
|
||||
launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is
|
||||
none), so existing PM2 configs keep working unchanged.
|
||||
- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then
|
||||
`npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to
|
||||
1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds.
|
||||
- e2e starts the backend with tsx.
|
||||
|
||||
## [1.50.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
|
||||
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
|
||||
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
|
||||
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
|
||||
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
|
||||
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
|
||||
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
|
||||
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
|
||||
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
|
||||
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
|
||||
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
|
||||
|
||||
### Changed
|
||||
|
||||
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
|
||||
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
|
||||
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
|
||||
events too.
|
||||
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
|
||||
|
||||
## [1.50.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
|
||||
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
|
||||
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
|
||||
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
|
||||
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
|
||||
used to stop silently at 200.
|
||||
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
|
||||
it used to show only the latest 50.
|
||||
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
|
||||
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
|
||||
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
|
||||
day). Both dates are now whole days in the site's time zone.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
|
||||
opens: it loads the latest payments, today's (for the totals), donations with what they were
|
||||
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
|
||||
registrations when they're picked. Faster as the years add up; the page works the same.
|
||||
|
||||
## [1.50.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
|
||||
payment, My Events and the self-service kiosk:
|
||||
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
|
||||
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
|
||||
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
|
||||
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
|
||||
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
|
||||
and its password eye can now be used with the keyboard.
|
||||
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
|
||||
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
|
||||
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
|
||||
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
|
||||
announced; option buttons say which one is chosen; error messages are announced when they
|
||||
appear.
|
||||
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
|
||||
so they don't rely on colour alone. The page honours "reduce motion" on the device.
|
||||
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
|
||||
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
|
||||
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
|
||||
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
|
||||
the kiosk switch). New dev dependency `@axe-core/playwright`.
|
||||
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
|
||||
|
||||
### Changed
|
||||
|
||||
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
|
||||
|
||||
## [1.49.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
|
||||
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
|
||||
when the page opens (but not over a link that already says what to show, such as the
|
||||
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
|
||||
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
|
||||
an account is anonymised.
|
||||
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
|
||||
`20261028180000_saved_filters`.
|
||||
|
||||
## [1.49.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
+21
-2
@@ -56,7 +56,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
|
||||
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
|
||||
It installs Node 24 LTS, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
|
||||
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
|
||||
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
|
||||
below to match.
|
||||
@@ -305,11 +305,30 @@ cd /opt/hope-events
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
|
||||
```
|
||||
|
||||
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
|
||||
It backs up the database, checks out the release, installs, migrates, builds (the backend too,
|
||||
since 1.51.0) and reloads PM2,
|
||||
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.
|
||||
|
||||
### Upgrading Node
|
||||
|
||||
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
|
||||
until April 2028). `deploy.sh` warns when a server is still on an older Node. To upgrade:
|
||||
|
||||
```bash
|
||||
node -v # what you have now
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
|
||||
sudo apt-get install -y nodejs # replaces the old Node
|
||||
sudo npm install -g pm2 # PM2 for the new Node
|
||||
cd /opt/hope-events
|
||||
sudo -u hopeevents -H pm2 update # restart PM2's daemon on the new Node
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh v1.51.4 # reinstall packages and rebuild for Node 24
|
||||
```
|
||||
|
||||
Use the version you're on (or a newer one) in the last line — the deploy reinstalls every package
|
||||
and rebuilds, which a Node upgrade needs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|
||||
+46
-10
@@ -35,10 +35,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
19. [Volunteer rosters](#volunteer-rosters)
|
||||
20. [Accounts & security](#accounts--security)
|
||||
21. [Privacy (POPIA)](#privacy-popia)
|
||||
22. [Help system](#help-system)
|
||||
23. [Background jobs](#background-jobs)
|
||||
24. [Operations](#operations)
|
||||
25. [Page map](#page-map)
|
||||
22. [Accessibility](#accessibility)
|
||||
23. [Help system](#help-system)
|
||||
24. [Background jobs](#background-jobs)
|
||||
25. [Operations](#operations)
|
||||
26. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -414,6 +415,12 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
|
||||
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
|
||||
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
|
||||
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
|
||||
events** lists past ones (latest first), and typing searches every event by name, campus or date.
|
||||
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
|
||||
- **Dashboard** — **Today** (each event or session on today: checked in so far
|
||||
against places, children in now, volunteers here; money in today and this
|
||||
week) and **Waiting for you** (unpaid registrations for the next two weeks,
|
||||
@@ -481,11 +488,15 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
(records the balance by method), move to another ticket (same tickets and QR
|
||||
codes, repriced), put in a room or group, export to Excel, cancel — each
|
||||
through the same rules as one at a time, with a result per registration.
|
||||
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
|
||||
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.
|
||||
- **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
|
||||
(created, edited, turned off, applied or removed by staff).
|
||||
(created, edited, turned off, applied or removed by staff), and every later feature's admin
|
||||
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
@@ -511,7 +522,7 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
@@ -542,7 +553,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
@@ -770,7 +781,7 @@ contact page shows the Information Officer.
|
||||
and the people affected were told, and drafts of both notifications.
|
||||
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
@@ -781,6 +792,18 @@ log.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
|
||||
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
|
||||
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
|
||||
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
|
||||
motion" respected. Branding warns when the primary colour is too light for white text and
|
||||
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
|
||||
(`e2e/accessibility.spec.ts`) keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -800,11 +823,15 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
|
||||
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
|
||||
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
|
||||
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
|
||||
**Resend**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
|
||||
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
|
||||
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
|
||||
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
|
||||
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
|
||||
is added to the operator register; deliveries are deleted after 30 days and with an account.
|
||||
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
|
||||
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
|
||||
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
|
||||
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
|
||||
|
||||
## Background jobs
|
||||
|
||||
@@ -862,7 +889,16 @@ can't start, the same jobs run on in-process timers.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
|
||||
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
|
||||
events, my account and registrations, registering, cancelling and paying online (1.51.1),
|
||||
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2;
|
||||
the scanner and at-the-door use it from 1.51.3); the frontend's v2 calls are typed from the
|
||||
OpenAPI document. v1 endpoints with a v2 replacement send `Deprecation` and a `successor-version`
|
||||
link (1.51.3).
|
||||
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
|
||||
deploys build `dist/`, which `src/index.js` runs.
|
||||
- **Command palette** (Ctrl+K): every page, settings tab, report and quick action for your role.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
|
||||
frontend against a separate, freshly seeded database: public pages, free and paid
|
||||
|
||||
@@ -107,7 +107,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
|
||||
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
It installs Node 24 LTS, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
|
||||
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
|
||||
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
|
||||
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
|
||||
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
|
||||
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
|
||||
|
||||
+138
@@ -12,6 +12,144 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.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
|
||||
costs and picture uploads now run on the new version of the server's connection to the app. They
|
||||
work just as before.
|
||||
|
||||
## 1.51.7
|
||||
|
||||
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
|
||||
from, now run on the new version of the server's connection to the app. They work as before.
|
||||
- The background job runner was updated. Its list of recent runs (Site settings → Background jobs)
|
||||
starts afresh with this version.
|
||||
|
||||
## 1.51.6
|
||||
|
||||
- **Find anything with Ctrl+K.** The search box (Ctrl+K) now lists every report, and pages that
|
||||
were missing: the dashboard, all reports, ticket options, your groups, and your own events,
|
||||
payments, privacy, kids, serving, forms and donations.
|
||||
- Excel downloads use a newer, maintained library. They look the same.
|
||||
|
||||
## 1.51.5
|
||||
|
||||
- **Updated framework.** The site now runs on the latest version of its web framework. Nothing
|
||||
changes in how it works.
|
||||
|
||||
## 1.51.4 — security
|
||||
|
||||
- **Security updates** to the parts the site is built on, including a critical fix in the web
|
||||
framework. Recommended for everyone.
|
||||
|
||||
## 1.51.3
|
||||
|
||||
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
|
||||
of the server's connection to the app, and the server's web framework has been updated. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.2
|
||||
|
||||
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
|
||||
new version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.1
|
||||
|
||||
- **More groundwork for 2.0.** Registering, paying online and cancelling now run on the new
|
||||
version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.0
|
||||
|
||||
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
|
||||
way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use
|
||||
Cross Events.
|
||||
|
||||
## 1.50.2
|
||||
|
||||
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
|
||||
the door, payments, messages, forms, attendance and more) there is now a search box. The list
|
||||
opens on upcoming events, past ones are one click away, and typing finds any event by name,
|
||||
campus or date. The events list in Reports can be searched too.
|
||||
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
|
||||
another system: what is sent and when, how to check it came from you, and an example message for
|
||||
every kind of event.
|
||||
|
||||
## 1.50.1
|
||||
|
||||
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
|
||||
IP address, filtered by role, and its action list now includes every kind of action. Data
|
||||
requests can be searched and come a page at a time, and the webhook delivery log can show only
|
||||
failed deliveries and load older ones.
|
||||
- **Fix:** the audit log's "To" date now includes that day.
|
||||
- **Faster Payments page** for churches with a few years of payments behind them.
|
||||
|
||||
## 1.50.0
|
||||
|
||||
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
|
||||
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
|
||||
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
|
||||
every form field, button and error message read out correctly. Faint grey text is darker and
|
||||
links in sentences are underlined, so they're easier to read.
|
||||
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
|
||||
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
|
||||
|
||||
## 1.49.2
|
||||
|
||||
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
|
||||
under a name and get back to them with one click. Star one and it's ready every time you open the
|
||||
page. They're yours and follow you to any device.
|
||||
|
||||
## 1.49.1
|
||||
|
||||
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
|
||||
|
||||
> **API v2 (1.51.0 on):** `/api/v2` is described by OpenAPI 3.1. The reference is at `/docs/v2`
|
||||
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
|
||||
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
|
||||
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
|
||||
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`). v1 endpoints that have a v2 replacement send `Deprecation` and
|
||||
> `Link: <v2 path>; rel="successor-version"` headers (1.51.3). The frontend calls v2 through the typed
|
||||
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
|
||||
|
||||
All authenticated requests require:
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
@@ -243,7 +251,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
|
||||
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
|
||||
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
@@ -570,7 +578,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
@@ -643,7 +651,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/api/payments` | supervisor+ | All payments |
|
||||
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
|
||||
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
|
||||
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
|
||||
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
|
||||
@@ -933,6 +941,18 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
|
||||
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
|
||||
role linked to the room); checked-in ones are counted in `leaders`.
|
||||
|
||||
## Saved filters
|
||||
|
||||
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
|
||||
else's id answers 404).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
|
||||
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
|
||||
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
|
||||
| DELETE | `/api/saved-filters/:id` | Delete |
|
||||
|
||||
## Bulk actions on registrations
|
||||
|
||||
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
|
||||
@@ -987,13 +1007,13 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
|
||||
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
|
||||
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
|
||||
|
||||
## Season passes
|
||||
|
||||
+12
-3
@@ -152,14 +152,22 @@ npm run prisma:generate
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
# Development (auto-reload; runs the TypeScript in src/ through tsx)
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
# Production: build once per update, then start (src/index.js runs the build in dist/)
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Type-check only
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for full API documentation (admin only).
|
||||
Since 1.51.0 the backend is partly TypeScript (`allowJs`: `.js` compiles as it is, `.ts` is checked
|
||||
strictly). `src/index.js` is a small launcher: with plain Node it runs `dist/` when `dist/build.json`
|
||||
matches this version, otherwise it compiles on the fly with tsx. The server itself is `src/app.js`.
|
||||
|
||||
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for the v1 API documentation and `/docs/v2` for the v2 (OpenAPI) reference — both admin only.
|
||||
|
||||
---
|
||||
|
||||
@@ -532,6 +540,7 @@ Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with
|
||||
### PM2 (recommended)
|
||||
```bash
|
||||
npm install -g pm2
|
||||
npm run build
|
||||
pm2 start src/index.js --name hope-events-api
|
||||
pm2 save && pm2 startup
|
||||
```
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
testMatch: ['**/tests/**/*.test.js', '**/tests/**/*.test.ts'],
|
||||
// TypeScript (src/**/*.ts and tests) runs through ts-jest; plain JS is left as it is.
|
||||
transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] },
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
};
|
||||
|
||||
Generated
+1399
-1345
File diff suppressed because it is too large
Load Diff
+32
-12
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.49.1",
|
||||
"version": "1.51.17",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"dev": "tsx watch src/index.js",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
@@ -14,34 +14,49 @@
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
|
||||
"set-demo": "node scripts/set-demo.js"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.9.0",
|
||||
"cron-parser": "^5.10.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"express": "^5.3.0",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"multer": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.0.16",
|
||||
"nodemailer": "^10.3.0",
|
||||
"pdfkit": "^0.17.1",
|
||||
"pg-boss": "^10.4.2",
|
||||
"pg-boss": "^11.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^11.1.1"
|
||||
"swagger-ui-dist": "^5.34.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/swagger-ui-dist": "^3.30.6",
|
||||
"jest": "^30.4.2",
|
||||
"prisma": "^7.10.1"
|
||||
"prisma": "^7.10.1",
|
||||
"supertest": "^7.3.1",
|
||||
"ts-jest": "^29.4.15"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1",
|
||||
@@ -54,6 +69,11 @@
|
||||
"prisma@7.10.1": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
"@parcel/watcher": false,
|
||||
"@scarf/scarf": false,
|
||||
"esbuild": false
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedFilter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"page" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"params" JSONB NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -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);
|
||||
@@ -164,6 +164,8 @@ model User {
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
aiCalls AiCall[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -819,6 +821,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
|
||||
@@ -2297,3 +2300,38 @@ model WebhookDelivery {
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
|
||||
/// events, reports), `params` the filter values. At most one default per person and page.
|
||||
model SavedFilter {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
page String
|
||||
name String
|
||||
params Json
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@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,22 @@
|
||||
/**
|
||||
* Writes the /api/v2 OpenAPI document to frontend/src/lib/api-v2/openapi.json, from which the
|
||||
* frontend's types are generated (`npm run api:types` in the repo root does both). Run it after
|
||||
* adding or changing a v2 route; tests/v2.test.ts fails while the saved copy is out of date.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
import '../src/v2';
|
||||
|
||||
export const OPENAPI_FILE = path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'api-v2', 'openapi.json');
|
||||
|
||||
// The version is left out so a release bump doesn't change the file.
|
||||
export const currentDocument = () => JSON.stringify(buildOpenApi({ version: 'v2' }), null, 2) + '\n';
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(OPENAPI_FILE), { recursive: true });
|
||||
fs.writeFileSync(OPENAPI_FILE, currentDocument());
|
||||
console.log(`Wrote ${path.relative(process.cwd(), OPENAPI_FILE)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -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 };
|
||||
+1832
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
const { page, limit, actorId, action, role, from, to, search } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
|
||||
const ExcelJS = require('@cj-tech-master/excelts');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
@@ -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,177 +1,44 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
// Planned event costs (cashup and the profit report). Shared with /api/v2 (services/setup.ts, 1.51.8).
|
||||
const setup = require('../services/setup');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
|
||||
|
||||
function normalizePaidFromMethod(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!ALL_METHODS.includes(value)) {
|
||||
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// @desc List costs for an event
|
||||
// @route GET /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const getEventCosts = async (req, res) => {
|
||||
try {
|
||||
const costs = await prisma.eventCost.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: { eventOption: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
res.json(costs);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await setup.listCosts(req.params.eventId));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Create a cost for an event
|
||||
// @route POST /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const createEventCost = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
if (!label || !String(label).trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Label is required');
|
||||
}
|
||||
if (costType !== 'once_off' && costType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
if (costType === 'per_item' && !eventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
if (costType === 'per_item') {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
|
||||
if (!option || option.eventId !== eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
const cost = await prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
label: String(label).trim(),
|
||||
costType,
|
||||
amount: amt,
|
||||
eventOptionId: costType === 'per_item' ? eventOptionId : null,
|
||||
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
|
||||
notes: notes || null
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.status(201).json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.status(201).json(await setup.createCost(req.params.eventId, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Update a cost
|
||||
// @route PUT /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const updateEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
const nextCostType = costType !== undefined ? costType : existing.costType;
|
||||
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
|
||||
const nextEventOptionId = nextCostType === 'per_item'
|
||||
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
|
||||
: null;
|
||||
if (nextCostType === 'per_item') {
|
||||
if (!nextEventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
|
||||
if (!option || option.eventId !== existing.eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
let nextAmount = existing.amount;
|
||||
if (amount !== undefined) {
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
nextAmount = amt;
|
||||
}
|
||||
|
||||
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
|
||||
|
||||
const cost = await prisma.eventCost.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
label: label !== undefined ? String(label).trim() : existing.label,
|
||||
costType: nextCostType,
|
||||
amount: nextAmount,
|
||||
eventOptionId: nextEventOptionId,
|
||||
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
|
||||
notes: notes !== undefined ? notes : existing.notes
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await setup.updateCost(req.params.id, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a cost
|
||||
// @route DELETE /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const deleteEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
await prisma.eventCost.delete({ where: { id: req.params.id } });
|
||||
await setup.deleteCost(req.params.id);
|
||||
res.json({ message: 'Cost deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCosts,
|
||||
createEventCost,
|
||||
updateEventCost,
|
||||
deleteEventCost
|
||||
};
|
||||
module.exports = { getEventCosts, createEventCost, updateEventCost, deleteEventCost };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -154,6 +154,9 @@ const test = async (req, res) => {
|
||||
const deliveries = async (req, res) => {
|
||||
try {
|
||||
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
|
||||
// "Load older": the next page is everything before the last one shown.
|
||||
const before = req.query.before ? new Date(String(req.query.before)) : null;
|
||||
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
|
||||
const rows = await prisma.webhookDelivery.findMany({
|
||||
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
|
||||
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
|
||||
|
||||
@@ -6,7 +6,6 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const payments = require('../payments');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -354,7 +353,15 @@ const getPayments = async (req, res) => {
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
|
||||
// ?donations=1 (donations and the legs that apply them to registrations).
|
||||
const filters = [];
|
||||
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : null;
|
||||
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
|
||||
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
|
||||
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
@@ -933,54 +940,14 @@ const createCheckout = async (req, res) => {
|
||||
throw new Error('Either registrationId or eventId is required');
|
||||
}
|
||||
|
||||
// A registration's balance
|
||||
// A registration's balance (shared with POST /api/v2/registrations/:id/checkout)
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
||||
}
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
await assertEventOpen(reg.eventId, res);
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
||||
if (priceChanged) {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
const newTotal = computeRegistrationTotalDue(freshReg, new Date());
|
||||
const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0);
|
||||
return res.status(200).json({
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.'
|
||||
});
|
||||
}
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (amount && !isNaN(parseFloat(amount))) {
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
||||
const { startRegistrationCheckout } = require('../services/payments');
|
||||
const result = await startRegistrationCheckout(registrationId, req.user, {
|
||||
amount: amount && !isNaN(parseFloat(amount)) ? parseFloat(amount) : null,
|
||||
donationAmount: donationAmount === undefined || donationAmount === null || donationAmount === '' ? null : parseFloat(donationAmount),
|
||||
successUrl, cancelUrl, failureUrl, provider,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
|
||||
const where = { selfService: false };
|
||||
if (status === 'open') where.status = 'open';
|
||||
else if (status === 'closed') where.status = { not: 'open' };
|
||||
const rows = await prisma.dataRequest.findMany({
|
||||
const q = String(req.query.search || '').trim().slice(0, 100);
|
||||
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
|
||||
// Closed requests pile up over the years, so they come a page at a time (newest first).
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
|
||||
where,
|
||||
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
|
||||
take: 200,
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
|
||||
});
|
||||
}), prisma.dataRequest.count({ where })]);
|
||||
const requests = await Promise.all(rows.map(async r => ({
|
||||
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
|
||||
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
|
||||
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
|
||||
overdue: r.status === 'open' && r.dueAt < new Date(),
|
||||
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
|
||||
})));
|
||||
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount });
|
||||
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const ExcelJS = require('exceljs');
|
||||
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
|
||||
const ExcelJS = require('@cj-tech-master/excelts');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
|
||||
@@ -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,189 +1,45 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
// Scanning sections: which ticket types may come in at which door. Shared with
|
||||
// /api/v2/sections (services/setup.ts, 1.51.8).
|
||||
const setup = require('../services/setup');
|
||||
const { listWhere } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
//
|
||||
// @desc Get sections (optionally by event)
|
||||
// @route GET /api/sections?eventId=xxx
|
||||
// @access Private/Staff
|
||||
//
|
||||
const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const { withScope, listWhere } = require('../utils/campusScope');
|
||||
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: {
|
||||
eventOption: true
|
||||
}
|
||||
},
|
||||
event: {
|
||||
select: { id: true, title: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.json(await setup.listSections(req.query.eventId, await listWhere(req, 'event')));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Create section
|
||||
// @route POST /api/sections
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const createSection = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, allowedOptionIds } = req.body;
|
||||
|
||||
if (!eventId || !name) {
|
||||
res.status(400);
|
||||
throw new Error('Event and section name are required');
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const section = await prisma.section.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Attach allowed options if provided
|
||||
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.status(201).json(await setup.createSection(req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Update section
|
||||
// @route PUT /api/sections/:id
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const updateSection = async (req, res) => {
|
||||
try {
|
||||
const { name, allowedOptionIds } = req.body;
|
||||
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
const updatedSection = await prisma.section.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name || section.name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// If allowed options supplied → reset them
|
||||
if (Array.isArray(allowedOptionIds)) {
|
||||
|
||||
await prisma.sectionOption.deleteMany({
|
||||
where: { sectionId: section.id }
|
||||
});
|
||||
|
||||
if (allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.json(await setup.updateSection(req.params.id, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Delete section
|
||||
// @route DELETE /api/sections/:id
|
||||
// @access Private/Admin
|
||||
//
|
||||
const deleteSection = async (req, res) => {
|
||||
try {
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
await prisma.section.delete({
|
||||
where: { id: section.id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Section deleted' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
await setup.deleteSection(req.params.id);
|
||||
res.json({ message: 'Section deleted' });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSections,
|
||||
createSection,
|
||||
updateSection,
|
||||
deleteSection
|
||||
};
|
||||
module.exports = { getSections, createSection, updateSection, deleteSection };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
// Scanning, shared with /api/v2/checkin (TypeScript, 1.51.2).
|
||||
const checkin = require('../services/checkin');
|
||||
|
||||
// @desc Generate tickets for a registration
|
||||
// @route POST /api/tickets/generate
|
||||
@@ -256,99 +257,11 @@ const getTicketByQrCode = async (req, res) => {
|
||||
// @access Private/Staff
|
||||
const getScanPreview = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t."qrCode",
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
ov.name AS "variantName",
|
||||
e.id AS "evId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
const usages = r.usages || [];
|
||||
// Multi-day events: check-in is per session, so what's left is counted for the
|
||||
// session this scan is for (the one picked, or today's).
|
||||
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
let session = null;
|
||||
let sessionError = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(r.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
|
||||
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
|
||||
} else {
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
remaining,
|
||||
session,
|
||||
sessionError,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages
|
||||
});
|
||||
const result = await checkin.previewScan(req.params.qrCode, { sessionId: req.query.sessionId || null });
|
||||
if (result.outcome === 'transferred') return res.status(410).json({ message: result.message, code: 'TICKET_TRANSFERRED' });
|
||||
res.json(result.ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,187 +270,19 @@ const getScanPreview = async (req, res) => {
|
||||
// @access Private/Staff
|
||||
const scanTicket = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
eo."isMainTicket" AS "isMainTicket",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', tu.id,
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const ticket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||
usages: r.usages || []
|
||||
};
|
||||
|
||||
// Optional server-side guard: ensure ticket belongs to the requested event when provided
|
||||
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Multi-day events: the check-in is for one session (the one picked, or today's), and
|
||||
// what's left is counted per session.
|
||||
let session = null;
|
||||
let sessionsUtil = null;
|
||||
let schedule = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
sessionsUtil = require('../utils/sessions');
|
||||
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
|
||||
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
|
||||
session = found.session;
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = session
|
||||
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
|
||||
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (session) {
|
||||
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
|
||||
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return res.status(403).json({
|
||||
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
return res.status(403).json({
|
||||
message: 'Ticket has already been fully used',
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
|
||||
if (!(req.body?.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { consentStatus, eventId: ticket.eventId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
const fullyUsed = newRemaining <= 0;
|
||||
|
||||
// Run the usage insert and ticket status update in parallel — they don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
ticketId: ticket.id,
|
||||
scannedById: req.user.id,
|
||||
quantityRedeemed: qtyToRedeem,
|
||||
sessionId: session ? session.id : null,
|
||||
}
|
||||
}),
|
||||
// A multi-session ticket is never "used up" — it's checked in again next session.
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
|
||||
// the QR camera flow are unaffected)
|
||||
if (ticket.registrationOption?.eventOption?.isMainTicket) {
|
||||
require('../utils/notifications')
|
||||
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||
}
|
||||
|
||||
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
|
||||
res.json({
|
||||
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
});
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const result = await checkin.scanTicket(req.params.qrCode, req.user, {
|
||||
eventId: req.query?.eventId || req.body?.eventId || null,
|
||||
sectionId: req.query?.sectionId || req.body?.sectionId || null,
|
||||
sessionId: req.body?.sessionId || req.query?.sessionId || null,
|
||||
deviceId: req.body?.deviceId || null,
|
||||
qty: parseInt(req.body?.qty || req.body?.quantity) || null,
|
||||
overrideConsent: req.body?.overrideConsent === true,
|
||||
}, getClientIp(req));
|
||||
const { status, body } = checkin.v1ScanResponse(result);
|
||||
res.status(status).json(body);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,28 +3,22 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
function getOrgName() {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
// Sign up, log in and profile changes are shared with /api/v2 (TypeScript, 1.51.2).
|
||||
const accounts = require('../services/auth');
|
||||
const { getOrgName, sendActivationLink, sendWelcomeEmail } = accounts;
|
||||
const { v1StatusOf } = require('../services/errors');
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
@@ -33,145 +27,10 @@ function getClientIp(req) {
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||
// account — via email if it has a real (non-guest) address, otherwise via
|
||||
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||
// inactive account, and immediately when an admin/supervisor creates an
|
||||
// account on someone's behalf (walk-in / manual registration).
|
||||
async function sendActivationLink(user) {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
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 activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} else {
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(
|
||||
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send welcome email with next upcoming events
|
||||
async function sendWelcomeEmail(user) {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
// Errors from utils/twoFactor and the services carry their own HTTP status; everything else
|
||||
// follows the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
|
||||
// accept a privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra
|
||||
});
|
||||
return v1StatusOf(error, res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
@@ -179,79 +38,9 @@ async function completeLogin(user, req, res, extra = {}) {
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// Validate preference — WhatsApp requires a valid phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
|
||||
pref = 'email'; // silently fall back if no valid number
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email },
|
||||
...(phone ? [{ phoneNumber: phone }] : [])
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
res.status(400);
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
phoneNumber: phone,
|
||||
notificationPreference: pref,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
|
||||
// way, so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
res.status(201).json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion)
|
||||
});
|
||||
} else {
|
||||
res.status(400);
|
||||
throw new Error('Invalid user data');
|
||||
}
|
||||
res.status(201).json(await accounts.signUp(req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -260,108 +49,12 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
// Normalize phone input — convert SA local format (0xx) → international (27xx)
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
|
||||
// Check for user email or phone
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: rawInput },
|
||||
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(401);
|
||||
throw new Error('User does not exist');
|
||||
const { email, password, deviceToken } = req.body || {};
|
||||
const result = await accounts.logIn({ identifier: email, password, deviceToken }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
|
||||
if (result.status === 'code_required') {
|
||||
return res.json({ twoFactorRequired: true, ...(result.enrolling ? { enrolling: true } : {}), ...result.challenge });
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// Resend the activation link on each failed login attempt against an inactive
|
||||
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||
await sendActivationLink(user);
|
||||
// If the account has a real email (not a guest placeholder), it went out via email
|
||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
}
|
||||
// No real email — if they have a phone number, it went out via WhatsApp
|
||||
if (user.phoneNumber) {
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000; // 5 min
|
||||
|
||||
// Check lock
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const now = new Date().getTime();
|
||||
const lockTime = new Date(user.lockUntil).getTime();
|
||||
|
||||
const diffMs = lockTime - now;
|
||||
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
||||
|
||||
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isMatch = await comparePassword(password, user.password);
|
||||
|
||||
if (!isMatch) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: attempts,
|
||||
lockUntil:
|
||||
attempts >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_TIME)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
// ✅ SUCCESS → reset attempts
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
await completeLogin(updated, req, res);
|
||||
res.json(result.session);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -373,30 +66,7 @@ const loginUser = async (req, res) => {
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
await completeLogin(user, req, res, extra);
|
||||
res.json(await accounts.verifyLoginCode({ challengeId, code, rememberDevice }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -565,114 +235,10 @@ const getUserProfile = async (req, res) => {
|
||||
// @access Private
|
||||
const updateUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
|
||||
newHomeCampusId = campus.id;
|
||||
}
|
||||
}
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is required to set a new password');
|
||||
}
|
||||
const matches = await comparePassword(currentPassword, user.password);
|
||||
if (!matches) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('New password must be at least 8 characters long');
|
||||
}
|
||||
newHashedPassword = await hashPassword(newPw);
|
||||
}
|
||||
|
||||
// Normalize phone
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) {
|
||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
}
|
||||
|
||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
const { user, twoFactorRequired, token } = await accounts.updateProfile(req.user.id, req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
|
||||
res.json({ ...user, twoFactorRequired, token });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -973,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 };
|
||||
|
||||
+26
-1791
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,13 @@
|
||||
* same jobs run on in-process timers instead, as before.
|
||||
*/
|
||||
|
||||
const cronParser = require('cron-parser');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
|
||||
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
|
||||
const SCHEMA = 'pgboss';
|
||||
// pg-boss 11 can't upgrade pg-boss 10's schema (it migrates only from 11.0), so it has its own.
|
||||
// The old `pgboss` schema (10.x) is left as it was; it can be dropped (DROP SCHEMA pgboss CASCADE)
|
||||
// once 1.51.7 has run — it only held queued runs and their history.
|
||||
const SCHEMA = 'pgboss_v11';
|
||||
|
||||
const JOBS = [
|
||||
{
|
||||
@@ -105,7 +108,7 @@ function cronFor(job) {
|
||||
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
|
||||
if (override) {
|
||||
try {
|
||||
cronParser.parseExpression(override, { tz: TIMEZONE });
|
||||
CronExpressionParser.parse(override, { tz: TIMEZONE });
|
||||
return override;
|
||||
} catch {
|
||||
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
|
||||
@@ -116,7 +119,7 @@ function cronFor(job) {
|
||||
|
||||
/** When a cron schedule next fires after `from`. */
|
||||
function nextRun(cron, from = new Date()) {
|
||||
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
return CronExpressionParser.parse(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
@@ -270,11 +273,8 @@ async function jobStatus(prisma) {
|
||||
FROM (
|
||||
SELECT name, state, created_on, started_on, completed_on, output,
|
||||
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
|
||||
FROM (
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
|
||||
UNION ALL
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
|
||||
) all_jobs
|
||||
-- pg-boss 11 keeps finished runs in job until they expire (there's no archive table).
|
||||
FROM (SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job) all_jobs
|
||||
WHERE name = ANY($1::text[])
|
||||
) ranked
|
||||
WHERE rn <= 10
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const filters = require('../utils/savedFilters');
|
||||
|
||||
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
|
||||
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
|
||||
|
||||
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
|
||||
router.get('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.post('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.put('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.delete('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Accounts: sign up, log in (with the 2FA code step) and changing your own details (1.51.2).
|
||||
* Moved from userController.js so /api/users/* (v1) and /api/v2/auth/*, /api/v2/me share it.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import axios from 'axios';
|
||||
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 auth = require('../config/auth') as {
|
||||
generateToken(id: string, role: string, tokenVersion: number): string;
|
||||
hashPassword(pw: string): Promise<string>;
|
||||
comparePassword(pw: string, hash: string): Promise<boolean>;
|
||||
};
|
||||
const twoFactor = require('../utils/twoFactor') as {
|
||||
createChallenge(user: any, purpose: string, method: string): Promise<{ challengeId: string; method: string; destination: string | null }>;
|
||||
verifyChallenge(challengeId: string, code: string, purpose: string): Promise<{ userId: string; method: string }>;
|
||||
issueTrustedDevice(userId: string, userAgent?: string): Promise<string>;
|
||||
isTrustedDevice(userId: string, deviceToken?: string): Promise<boolean>;
|
||||
clearTrustedDevices(userId: string): Promise<unknown>;
|
||||
requiresTwoFactor(user: { role: string }): boolean;
|
||||
enforcementMethod(user: any): Promise<string | null>;
|
||||
};
|
||||
const { logSecurityEvent } = require('../utils/securityEvents') as { logSecurityEvent(e: object): Promise<unknown> };
|
||||
const { getSettingSync } = require('../utils/settingsCache') as { getSettingSync(key: string, fallback: string): string };
|
||||
const whatsapp = () => require('../utils/whatsapp') as { normalizeZAPhone(p: unknown): string | null; isValidZAPhone(p: unknown): boolean };
|
||||
|
||||
/** Who is asking, for security notices and the activity log. */
|
||||
export type RequestInfo = { ip?: string; userAgent?: string };
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
export function getOrgName(): string {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
const frontendUrl = () => (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* Fire-and-forget: create a 24h activation token and deliver it to an inactive account — by
|
||||
* email if it has a real (non-guest) address, otherwise by WhatsApp if it has a phone number.
|
||||
* Used when a login hits an inactive account, and when staff create an account for someone.
|
||||
*/
|
||||
export async function sendActivationLink(user: { id: string; name?: string | null; email?: string | null; phoneNumber?: string | null }): Promise<void> {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false }, data: { used: true } });
|
||||
await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } });
|
||||
const activationUrl = `${frontendUrl()}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch((e: any) => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} else {
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
'Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:',
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
'_This link expires in 24 hours._',
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch((e: any) => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: a login notice with the approximate place it came from.
|
||||
async function sendLoginNotification(user: any, info: RequestInfo): Promise<void> {
|
||||
try {
|
||||
const ip = info.ip || 'unknown';
|
||||
const userAgent = info.userAgent || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`, { timeout: 3000 });
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e: any) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget: the welcome email, with the next few upcoming events. */
|
||||
export async function sendWelcomeEmail(user: any): Promise<void> {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e: any) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
export const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
} as const;
|
||||
|
||||
type LoginUser = { id: string; name: string; email: string; role: string; tokenVersion: number; newsAskedAt: Date | null; noticeVersionAccepted: number | null } & Record<string, unknown>;
|
||||
|
||||
export type Session = {
|
||||
id: string; name: string; email: string; role: string; token: string;
|
||||
newsPromptPending: boolean; noticePromptPending: false | 'first' | 'updated'; deviceToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Final step of a successful login (after the password, and the 2FA code when required):
|
||||
* notify, record the activity, and issue the session token.
|
||||
*/
|
||||
export async function completeLogin(user: LoginUser, info: RequestInfo, extra: { deviceToken?: string } = {}): Promise<Session> {
|
||||
const noticePromptPending = await (require('../utils/privacyNotice') as { noticePromptPending(u: object): Promise<false | 'first' | 'updated'> }).noticePromptPending(user).catch(() => false as const);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
sendLoginNotification(user, info).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: auth.generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (NewsPrompt on the frontend), and to accept a
|
||||
// privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export type SignUpInput = { name: string; email: string; password: string; phoneNumber?: string | null; notificationPreference?: string; newsOptIn?: boolean };
|
||||
|
||||
/** A new account (signed in straight away), with the news choice and privacy notice recorded. */
|
||||
export async function signUp(input: SignUpInput, info: RequestInfo) {
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = input;
|
||||
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// WhatsApp needs a valid phone number; otherwise quietly use email
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(String(notificationPreference)) ? String(notificationPreference) : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) pref = 'email';
|
||||
|
||||
const userExists = await prisma.user.findFirst({ where: { OR: [{ email }, ...(phone ? [{ phoneNumber: phone }] : [])] } });
|
||||
if (userExists) fail(409, 'User already exists', undefined, { v1Status: 400 });
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { id: uuidv4(), name, email, password: await auth.hashPassword(password), phoneNumber: phone, notificationPreference: pref as 'email' | 'whatsapp' | 'both', updatedAt: new Date() },
|
||||
});
|
||||
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either way,
|
||||
// so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: info.ip, answered: true, logAll: true },
|
||||
).catch((e: any) => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: info.ip })
|
||||
.catch((e: any) => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role, token: auth.generateToken(user.id, user.role, user.tokenVersion) };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Checks an email (or phone number) and password. Five wrong passwords lock the account for
|
||||
* five minutes. With 2FA (or when the role needs it), a code is sent instead of a token.
|
||||
*/
|
||||
export async function logIn(input: { identifier: string; password: string; deviceToken?: string }, info: RequestInfo): Promise<LoginResult> {
|
||||
const rawInput = String(input.identifier || '');
|
||||
// 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 }] : [])] } });
|
||||
// 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.
|
||||
await sendActivationLink(user);
|
||||
if (user.email && !user.email.endsWith('@guest.local')) fail(401, 'Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
if (user.phoneNumber) fail(401, 'Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
fail(401, 'Your account has been deactivated');
|
||||
}
|
||||
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const minutes = Math.ceil((new Date(user.lockUntil).getTime() - Date.now()) / 60000);
|
||||
fail(429, `Too many attempts. Try again in ${minutes} minute(s).`, undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
if (!(await auth.comparePassword(input.password, user.password))) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_TIME) : null } });
|
||||
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
|
||||
|
||||
// Two-factor: the password alone isn't enough unless this browser was remembered earlier.
|
||||
if (updated.twoFactorEnabled && !(await twoFactor.isTrustedDevice(updated.id, input.deviceToken))) {
|
||||
return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', updated.twoFactorMethod || 'email'), enrolling: false };
|
||||
}
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the first channel that
|
||||
// can take one; verifying it turns 2FA on. A trusted device can't skip this.
|
||||
if (!updated.twoFactorEnabled && twoFactor.requiresTwoFactor(updated)) {
|
||||
const method = await twoFactor.enforcementMethod(updated);
|
||||
if (method) return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', method), enrolling: true };
|
||||
}
|
||||
return { status: 'signed_in', session: await completeLogin(updated, info) };
|
||||
}
|
||||
|
||||
/** The second login step: the emailed or WhatsApp code for a token (and optionally remember this device). */
|
||||
export async function verifyLoginCode(input: { challengeId: string; code: string; rememberDevice?: boolean }, info: RequestInfo): Promise<Session> {
|
||||
const challenge = await twoFactor.verifyChallenge(input.challengeId, input.code, 'login');
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) return fail(401, 'Your account has been deactivated');
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the channel works,
|
||||
// so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && twoFactor.requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorMethod: challenge.method as 'email' | 'whatsapp', updatedAt: new Date() }, select: LOGIN_USER_SELECT });
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
}
|
||||
const extra = input.rememberDevice === true ? { deviceToken: await twoFactor.issueTrustedDevice(user.id, info.userAgent) } : {};
|
||||
return completeLogin(user, info, extra);
|
||||
}
|
||||
|
||||
export type ProfileInput = {
|
||||
name?: string; email?: string; phoneNumber?: string | null; notificationPreference?: string; homeCampusId?: string | null;
|
||||
/** A new password; needs currentPassword. */
|
||||
password?: string; currentPassword?: string;
|
||||
};
|
||||
|
||||
export const PROFILE_SELECT = {
|
||||
id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, twoFactorEnabled: true,
|
||||
twoFactorMethod: true, createdAt: true, updatedAt: true, isActive: true, homeCampusId: true, tokenVersion: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Changes your own details. A new password needs the current one, signs out other devices'
|
||||
* remembered 2FA, and sends a security notice. Returns the account and a fresh token.
|
||||
*/
|
||||
export async function updateProfile(userId: string, input: ProfileInput, info: RequestInfo) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return fail(404, 'User not found');
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = input;
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) fail(400, 'Choose one of the campuses in the list.');
|
||||
newHomeCampusId = campus!.id;
|
||||
}
|
||||
}
|
||||
|
||||
// A password change needs the current password
|
||||
let newHashedPassword: string | undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword) fail(400, 'Current password is required to set a new password');
|
||||
if (!(await auth.comparePassword(currentPassword!, user.password))) fail(400, 'Current password is incorrect');
|
||||
if (newPw.length < 8) fail(400, 'New password must be at least 8 characters long');
|
||||
newHashedPassword = await auth.hashPassword(newPw);
|
||||
}
|
||||
|
||||
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
|
||||
// WhatsApp needs a valid SA phone number
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email.
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name || user.name, email: email || user.email, password: newHashedPassword ?? user.password, phoneNumber: newPhone,
|
||||
notificationPreference: newPref, twoFactorMethod: newTwoFactorMethod, homeCampusId: newHomeCampusId, updatedAt: new Date(),
|
||||
},
|
||||
select: PROFILE_SELECT,
|
||||
});
|
||||
|
||||
if (newHashedPassword) {
|
||||
await twoFactor.clearTrustedDevices(updated.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updated.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updated.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch((e: any) => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updated, content.text).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'password_changed', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
}
|
||||
|
||||
return { user: updated, twoFactorRequired: twoFactor.requiresTwoFactor(updated), token: auth.generateToken(updated.id, updated.role, updated.tokenVersion) };
|
||||
}
|
||||
|
||||
export { ServiceError };
|
||||
@@ -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,286 @@
|
||||
/**
|
||||
* Checking a ticket in by its QR code (1.51.2). Moved from ticketController.js so
|
||||
* /api/tickets/scan-preview|scan (v1) and /api/v2/checkin/{qrCode} share it.
|
||||
*
|
||||
* Scanning has several normal answers besides "checked in" (already used, wrong event, consent
|
||||
* missing…). They come back as an `outcome`, not thrown, so each caller can show them its way;
|
||||
* only an unknown code is an error (404).
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const transfer = () => require('../utils/ticketTransfer') as { findTransferByOldCode(code: string): Promise<any>; transferredMessage(t: any): string };
|
||||
const consent = () => require('../utils/consent') as { consentStatusForTickets(ids: string[]): Promise<Map<string, string>> };
|
||||
type Session = { id: string; label?: string | null; startsAt: Date };
|
||||
const sessions = () => require('../utils/sessions') as {
|
||||
TIMEZONE: string;
|
||||
eventSchedule(eventId: string): Promise<{ sessions: Session[] } & Record<string, unknown>>;
|
||||
ticketScanSession(schedule: any, list: Session[], pinned: string | null, picked: string | null, now: Date, chosen: string[] | null): { session: Session | null; error: string | null };
|
||||
sessionLabel(session: Session, list: Session[]): string;
|
||||
redeemedForSession(usages: Usage[], sessionId: string): number;
|
||||
};
|
||||
type Usage = { id?: string; quantityRedeemed: number | null; scannedAt: string; sessionId: string | null };
|
||||
|
||||
export type ConsentStatus = 'not_required' | 'given' | 'pending' | 'declined' | 'missing' | string;
|
||||
|
||||
/** A ticket that was handed on: the old code tells the scanner who has it now. */
|
||||
async function transferredOutcome(qrCode: string) {
|
||||
const t = transfer();
|
||||
const found = await t.findTransferByOldCode(qrCode);
|
||||
return found ? { outcome: 'transferred' as const, message: t.transferredMessage(found) } : null;
|
||||
}
|
||||
|
||||
export type PreviewResult =
|
||||
| { outcome: 'transferred'; message: string }
|
||||
| {
|
||||
outcome: 'found';
|
||||
ticket: {
|
||||
id: string; quantity: number; isUsed: boolean; eventId: string; consentStatus: ConsentStatus; remaining: number;
|
||||
session: { id: string; label: string } | null; sessionError: string | null;
|
||||
registrationOption: { id: string | null; eventOption: { id: string | null; name: string | null }; variant: { id: string; name: string } | null };
|
||||
event: { id: string; title: string }; user: { id: string; name: string }; attendee: { name: string } | null;
|
||||
usages: Usage[];
|
||||
};
|
||||
};
|
||||
|
||||
/** What a scan would do, without checking anything in (the scanner's preview card). */
|
||||
export async function previewScan(qrCode: string, opts: { sessionId?: string | null } = {}): Promise<PreviewResult> {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
t.id, t."qrCode", t.quantity, t."isUsed", t."eventId",
|
||||
ro.id AS "registrationOptionId", ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId", eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId", ov.name AS "variantName",
|
||||
e.id AS "evId", e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
|
||||
u.id AS "userId", u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(json_build_object('quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
|
||||
FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
|
||||
|
||||
const r = rows[0];
|
||||
const consentStatus = (await consent().consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
const usages: Usage[] = r.usages || [];
|
||||
// Multi-day events: check-in is per session, so what's left is counted for the
|
||||
// session this scan is for (the one picked, or today's).
|
||||
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
let session: { id: string; label: string } | null = null;
|
||||
let sessionError: string | null = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const s = sessions();
|
||||
const schedule = await s.eventSchedule(r.eventId);
|
||||
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, opts.sessionId || null, new Date(), r.chosenSessionIds);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: s.sessionLabel(found.session, schedule.sessions) };
|
||||
remaining = Number(r.quantity) - s.redeemedForSession(usages, found.session.id);
|
||||
} else {
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'found',
|
||||
ticket: {
|
||||
id: r.id, quantity: Number(r.quantity), isUsed: r.isUsed, eventId: r.eventId, consentStatus, remaining, session, sessionError,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ScanTicket = {
|
||||
id: string; quantity: number; isUsed: boolean; eventId: string; event: { title: string };
|
||||
registrationOption: { eventOption: { id: string | null; name: string | null; isMainTicket: boolean | null } };
|
||||
usages: Usage[];
|
||||
};
|
||||
|
||||
export type ScanOptions = {
|
||||
/** Refuse a ticket for any other event (the scanner's chosen event). */
|
||||
eventId?: string | null;
|
||||
/** Only tickets valid for this section. */
|
||||
sectionId?: string | null;
|
||||
/** Multi-day events: the session (default: today's). */
|
||||
sessionId?: string | null;
|
||||
/** The scanning device, so its own offline lock doesn't block it. */
|
||||
deviceId?: string | null;
|
||||
/** How many people to check in (default: everyone left). */
|
||||
qty?: number | null;
|
||||
/** Supervisors and admins: check in without parental consent (logged). */
|
||||
overrideConsent?: boolean;
|
||||
};
|
||||
|
||||
export type ScanResult =
|
||||
| { outcome: 'checked_in'; message: string; session: { id: string; label: string } | null; ticketUsage: { id: string; scannedAt: Date; quantityRedeemed: number; sessionId: string | null }; ticket: ScanTicket; qtyRedeemed: number; totalRedeemed: number; remaining: number }
|
||||
| { outcome: 'transferred'; message: string }
|
||||
| { outcome: 'wrong_event' | 'section_not_found' | 'wrong_section' | 'no_session'; message: string; ticket: ScanTicket }
|
||||
| { outcome: 'locked'; message: string }
|
||||
| { outcome: 'already_used'; message: string; ticket: ScanTicket }
|
||||
| { outcome: 'consent_required'; message: string; consentStatus: ConsentStatus; canOverride: boolean; ticket: ScanTicket };
|
||||
|
||||
/** Checks a ticket in (all of it, or `qty` people), or says why it can't be. */
|
||||
export async function scanTicket(qrCode: string, actor: { id: string; role: Role }, opts: ScanOptions = {}, ip?: string): Promise<ScanResult> {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
t.id, t.quantity, t."isUsed", t."eventId",
|
||||
e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId", eo.name AS "eventOptionName", eo."isMainTicket" AS "isMainTicket",
|
||||
COALESCE(
|
||||
json_agg(json_build_object('id', tu.id, 'quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
|
||||
FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${qrCode}
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
|
||||
|
||||
const r = rows[0];
|
||||
const ticket: ScanTicket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||
usages: r.usages || [],
|
||||
};
|
||||
|
||||
// Ensure the ticket belongs to the event being scanned, when given
|
||||
const providedEventId = String(opts.eventId || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) return { outcome: 'wrong_event', message: 'Ticket belongs to a different event', ticket };
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String(opts.sectionId || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) return { outcome: 'section_not_found', message: 'Section not found for this event', ticket };
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) return { outcome: 'wrong_section', message: 'This ticket is not valid for this section', ticket };
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks') as { blockingLock(target: object, opts: object): Promise<any> };
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: opts.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return { outcome: 'locked', message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.` };
|
||||
}
|
||||
|
||||
// Multi-day events: the check-in is for one session (the one picked, or today's), and
|
||||
// what's left is counted per session.
|
||||
let session: Session | null = null;
|
||||
let schedule: { sessions: Session[] } | null = null;
|
||||
const s = sessions();
|
||||
if (r.scheduleType === 'sessions') {
|
||||
schedule = await s.eventSchedule(ticket.eventId);
|
||||
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(opts.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
|
||||
if (!found.session) return { outcome: 'no_session', message: found.error || 'No session is open for check-in now', ticket };
|
||||
session = found.session;
|
||||
}
|
||||
const label = session && schedule ? s.sessionLabel(session, schedule.sessions) : '';
|
||||
|
||||
const totalRedeemed = session
|
||||
? s.redeemedForSession(ticket.usages, session.id)
|
||||
: ticket.usages.reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (session) {
|
||||
const last = ticket.usages.filter(u => u.sessionId === session!.id).sort((a, b) => +new Date(b.scannedAt) - +new Date(a.scannedAt))[0];
|
||||
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: s.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return { outcome: 'already_used', message: `Already checked in for ${label}${at ? ` at ${at}` : ''}`, ticket };
|
||||
}
|
||||
return { outcome: 'already_used', message: 'Ticket has already been fully used', ticket };
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const consentStatus = (await consent().consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(actor.role);
|
||||
if (!(opts.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return { outcome: 'consent_required', message: `${why} — this ticket can't be checked in.`, consentStatus, canOverride, ticket };
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'consent_override', targetType: 'Ticket', targetId: ticket.id, metadata: { consentStatus, eventId: ticket.eventId }, ip });
|
||||
}
|
||||
|
||||
// How many to check in this scan (defaults to all remaining)
|
||||
const requestedQty = Math.trunc(Number(opts.qty)) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
|
||||
// The usage insert and the ticket's status don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: { id: uuidv4(), ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: qtyToRedeem, sessionId: session ? session.id : null },
|
||||
}),
|
||||
// A multi-session ticket is never "used up" — it's checked in again next session.
|
||||
prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: session ? false : newRemaining <= 0, updatedAt: new Date() } }),
|
||||
]);
|
||||
|
||||
// Only Main Tickets trigger a check-in notification
|
||||
if (ticket.registrationOption.eventOption.isMainTicket) {
|
||||
(require('../utils/notifications') as { sendCheckInEmails(id: string, qty: number, total: number, left: number): Promise<unknown> })
|
||||
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: 'checked_in',
|
||||
message: `Ticket scanned successfully${session ? ` for ${label}` : ''} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
session: session ? { id: session.id, label } : null,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
/** The HTTP status and body v1 (/api/tickets/scan/:qrCode) has always answered for each outcome. */
|
||||
export function v1ScanResponse(result: ScanResult): { status: number; body: Record<string, unknown> } {
|
||||
const { outcome, ...rest } = result;
|
||||
switch (outcome) {
|
||||
case 'checked_in': return { status: 200, body: rest };
|
||||
case 'transferred': return { status: 410, body: { ...rest, code: 'TICKET_TRANSFERRED' } };
|
||||
case 'locked': return { status: 423, body: { ...rest, code: 'SCAN_LOCKED' } };
|
||||
case 'no_session': return { status: 400, body: { ...rest, code: 'NO_SESSION' } };
|
||||
case 'already_used': return { status: 403, body: { ...rest, remaining: 0 } };
|
||||
case 'consent_required': return { status: 403, body: { ...rest, code: 'CONSENT_REQUIRED' } };
|
||||
default: return { status: 400, body: rest };
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* An error from a service (src/services/*) with the HTTP status it means. v2 turns it into its
|
||||
* error shape by the status (v2/errors.ts); v1 controllers keep answering as they always did —
|
||||
* `v1Status` when v1 used a different status for it (e.g. 400 for a wrong password).
|
||||
*/
|
||||
export class ServiceError extends Error {
|
||||
readonly statusCode: number;
|
||||
/** Extra, e.g. a code the caller can switch on. */
|
||||
readonly code?: string;
|
||||
readonly v1Status?: number;
|
||||
|
||||
constructor(statusCode: number, message: string, code?: string, opts: { v1Status?: number } = {}) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.v1Status = opts.v1Status;
|
||||
}
|
||||
}
|
||||
|
||||
export const fail = (statusCode: number, message: string, code?: string, opts?: { v1Status?: number }): never => {
|
||||
throw new ServiceError(statusCode, message, code, opts);
|
||||
};
|
||||
|
||||
/** The status a v1 controller answers for an error (its old one where it differs). */
|
||||
export const v1StatusOf = (error: { v1Status?: number; statusCode?: number } | null | undefined, fallback: number): number =>
|
||||
error?.v1Status || error?.statusCode || fallback;
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* One event in full, its ticket options and variants, attachments, calendar file and short link
|
||||
* (1.51.8). Moved from eventController.js so /api/events/* (v1) and /api/v2/events/* share it.
|
||||
* The pre-migration fallbacks (attachments kept in JSON manifests on disk, clients without the
|
||||
* tier/variant models) are gone: every install has those tables.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 {
|
||||
isPickOption(event: any, option: any): boolean;
|
||||
activeSessions(sessions: any[]): Array<{ id: string }>;
|
||||
sessionBookings(eventId: string): Promise<Map<string, number>>;
|
||||
forgetSchedule(eventId: string): void;
|
||||
};
|
||||
const { publicOptions } = require('../utils/volunteers') as { publicOptions<O extends { id: string }>(event: any): O[] };
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object): Promise<number> };
|
||||
|
||||
/** Where uploaded attachments live (served at /uploads/event-files/…). */
|
||||
export const ATTACHMENTS_DIR = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
export const ALLOWED_DOCS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
|
||||
|
||||
/** A stored path as an absolute URL (the caller knows the host the request came to). */
|
||||
export type UrlOf = (stored: string | null | undefined) => string | null;
|
||||
|
||||
/**
|
||||
* Who's looking: staff of a campus they may see get everything (inactive and not-yet-live
|
||||
* events, who created it, who gets its emails, the kids/volunteer tickets); everyone else gets
|
||||
* the public view and NOT_FOUND for events that aren't public.
|
||||
*/
|
||||
export type Viewer = { staff: boolean; scope: object | null };
|
||||
|
||||
const { canView } = require('../utils/campusScope') as { canView(scope: object | null, campusId: string | null): boolean };
|
||||
/** Staff see an event in full when it's at a campus they work with (or campuses are off for them). */
|
||||
const staffViewOf = (viewer: Viewer, campusId: string | null) => viewer.staff && canView(viewer.scope, campusId);
|
||||
|
||||
const notPublic = (ev: { isActive: boolean | null; goLiveAt: Date | null }) =>
|
||||
ev.isActive === false || (ev.goLiveAt != null && new Date() < new Date(ev.goLiveAt));
|
||||
|
||||
/**
|
||||
* One event in full: ticket options with what's left (per variant, and per session for
|
||||
* "pick sessions" tickets), sessions with bookings, the attendee form, attachments, bundle deals
|
||||
* on sale and the next event in its series.
|
||||
*/
|
||||
export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: UrlOf) {
|
||||
const event: any = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
// (createdBy and notifyRecipients are dropped again below for the public view)
|
||||
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
attachments: true,
|
||||
...(viewer.staff ? {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
notifyRecipients: { select: { id: true, name: true, email: true, role: true } },
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!staffViewOf(viewer, event.campusId)) {
|
||||
// Campus-scoped staff get the public view of other campuses' events.
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
if (notPublic(event)) fail(404, 'Event not found');
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
}
|
||||
|
||||
const f = await prisma.eventForm.findUnique({ where: { eventId }, include: { fields: true } });
|
||||
const form = f ? {
|
||||
id: f.id,
|
||||
isRequired: !!f.isRequired,
|
||||
fields: [...f.fields].sort((a, b) => (a.order || 0) - (b.order || 0)).map(fl => ({
|
||||
id: fl.id, type: fl.type, label: fl.label, isRequired: !!fl.isRequired, order: fl.order || 0, helpText: fl.helpText || null, options: fl.options || null,
|
||||
})),
|
||||
} : null;
|
||||
|
||||
// What's left per option and variant (taken = non-cancelled registrations + places held for
|
||||
// live waitlist offers). "Pick sessions" tickets have places per session instead.
|
||||
const eventOptions = await Promise.all((event.eventOptions || []).map(async (opt: any) => {
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability: Record<string, number> = {};
|
||||
if (opt.stockLimit > 0) {
|
||||
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
||||
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
||||
}
|
||||
}
|
||||
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
||||
}
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const variants = await Promise.all((opt.variants || []).map(async (v: any) => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
return { ...opt, soldCount, availableCount: opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null, variants };
|
||||
}));
|
||||
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const { nextInSeries } = require('../utils/series') as { nextInSeries(ev: unknown): Promise<unknown> };
|
||||
return withSchedule({
|
||||
...event,
|
||||
eventOptions,
|
||||
picture: urlOf(event.picture),
|
||||
attachments: (event.attachments || []).map((att: any) => ({ ...att, url: urlOf(att.url) })),
|
||||
form,
|
||||
bundles: await publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
}
|
||||
|
||||
/** The event (or each session of a multi-day event) as an .ics calendar file. */
|
||||
export async function eventCalendar(eventId: string, viewer: Viewer): Promise<{ filename: string; ics: string }> {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, campusId: true, sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
if (!event || (!staffViewOf(viewer, event.campusId) && notPublic(event))) return fail(404, 'Event not found');
|
||||
const { buildEventIcs } = require('../utils/icsUtils') as { buildEventIcs(ev: unknown, url: string): string };
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's URL check needs a scheme.
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) frontendUrl = `https://${frontendUrl}`;
|
||||
return {
|
||||
filename: `${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics`,
|
||||
ics: buildEventIcs(event, `${frontendUrl}/events/${event.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
/** An active event by its short link (`/camp2026`), any letter case. */
|
||||
export async function eventByAlias(alias: string) {
|
||||
if (!VALID_ALIAS.test(alias)) return fail(404, 'Event not found');
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { redirectUrl: { equals: alias, mode: 'insensitive' }, isActive: true },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, registrationDeadline: true, goLiveAt: true, price: true, picture: true, isActive: true, redirectUrl: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found or inactive');
|
||||
const now = new Date();
|
||||
const isLive = (!event.startDate || new Date(event.startDate) >= now) && (!event.endDate || new Date(event.endDate) >= now);
|
||||
return { ...event, isLive };
|
||||
}
|
||||
|
||||
// ─── Ticket options and variants ─────────────────────────────────────────────
|
||||
|
||||
/** A staff price: undefined keeps `current`, '' or null clears it. */
|
||||
export function parseStaffPrice(value: unknown, current: number | null = null): number | null {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(String(value));
|
||||
if (!(n >= 0)) fail(400, 'Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
const openEvent = async (eventId: string) => {
|
||||
try {
|
||||
await assertEventOpen(eventId);
|
||||
} catch (e: any) {
|
||||
fail(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export type OptionInput = {
|
||||
name?: string; price?: number | string; isMainTicket?: boolean; stockLimit?: number | string; staffPrice?: number | string | null;
|
||||
sessionMode?: string; earlyBirdTiers?: any[]; variants?: any[];
|
||||
};
|
||||
|
||||
export async function createOption(eventId: string, input: OptionInput) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
await openEvent(eventId);
|
||||
if (!input.name) fail(400, 'Give the ticket a name');
|
||||
const option = await prisma.eventOption.create({
|
||||
data: {
|
||||
id: uuidv4(), eventId, name: String(input.name), price: parseFloat(String(input.price ?? 0)) || 0, isMainTicket: input.isMainTicket || false,
|
||||
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0, staffPrice: parseStaffPrice(input.staffPrice),
|
||||
sessionMode: input.sessionMode === 'pick' ? 'pick' : 'all',
|
||||
},
|
||||
});
|
||||
sessionsUtil.forgetSchedule(eventId);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a ticket option, and — when sent — its early-bird tiers and variants. Tiers and
|
||||
* variants are upserted by id, so purchases keep pointing at the tier they were bought under;
|
||||
* ones left out are removed only when nothing was bought under them.
|
||||
*/
|
||||
export async function updateOption(optionId: string, input: OptionInput) {
|
||||
const existing = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!existing) return fail(404, 'Event option not found');
|
||||
await openEvent(existing.eventId);
|
||||
const updated = await prisma.eventOption.update({
|
||||
where: { id: optionId },
|
||||
data: {
|
||||
name: input.name !== undefined ? String(input.name) : existing.name,
|
||||
price: input.price !== undefined ? parseFloat(String(input.price)) : existing.price,
|
||||
isMainTicket: input.isMainTicket !== undefined ? input.isMainTicket : existing.isMainTicket,
|
||||
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : existing.stockLimit,
|
||||
staffPrice: parseStaffPrice(input.staffPrice, existing.staffPrice),
|
||||
sessionMode: input.sessionMode !== undefined ? (input.sessionMode === 'pick' ? 'pick' : 'all') : existing.sessionMode,
|
||||
},
|
||||
});
|
||||
|
||||
// Early-bird tiers: deleting and recreating them would give every tier a new id and cut past
|
||||
// purchases off from the tier they were bought under (and reset its stock count).
|
||||
if (Array.isArray(input.earlyBirdTiers)) {
|
||||
const incoming = input.earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
for (const et of await prisma.earlyBirdTier.findMany({ where: { eventOptionId: optionId } })) {
|
||||
if (incoming.includes(et.id)) continue;
|
||||
// Never delete a tier still attributed on past purchases.
|
||||
if (await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } }) === 0) {
|
||||
await prisma.earlyBirdTier.delete({ where: { id: et.id } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < input.earlyBirdTiers.length; i++) {
|
||||
const t = input.earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || t.price === undefined || t.price === null) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
const data = { eventOptionId: optionId, variantId: t.variantId || null, deadline, price: p, order: typeof t.order === 'number' ? t.order : i, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0 };
|
||||
if (t.id) await prisma.earlyBirdTier.upsert({ where: { id: t.id }, update: data, create: { id: t.id, ...data } });
|
||||
else await prisma.earlyBirdTier.create({ data: { id: uuidv4(), ...data } });
|
||||
}
|
||||
}
|
||||
|
||||
// Variants: upserted by id; ones left out are deleted only when nobody has bought them.
|
||||
if (Array.isArray(input.variants)) {
|
||||
const incoming = input.variants.filter(v => v.id).map(v => v.id);
|
||||
for (const ov of await prisma.optionVariant.findMany({ where: { eventOptionId: optionId } })) {
|
||||
if (!incoming.includes(ov.id) && await prisma.registrationOption.count({ where: { variantId: ov.id } }) === 0) {
|
||||
await prisma.optionVariant.delete({ where: { id: ov.id } });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < input.variants.length; i++) {
|
||||
const v = input.variants[i];
|
||||
if (!v.name) continue;
|
||||
const data = {
|
||||
name: v.name,
|
||||
price: v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null,
|
||||
stockLimit: v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(v.staffPrice),
|
||||
order: i,
|
||||
};
|
||||
if (v.id) await prisma.optionVariant.update({ where: { id: v.id }, data });
|
||||
else await prisma.optionVariant.create({ data: { id: uuidv4(), eventOptionId: optionId, ...data } });
|
||||
}
|
||||
}
|
||||
|
||||
if (input.stockLimit !== undefined || input.variants !== undefined) {
|
||||
(require('../utils/waitlist') as { queueWaitlist(eventId: string): void }).queueWaitlist(existing.eventId);
|
||||
}
|
||||
return prisma.eventOption.findUniqueOrThrow({ where: { id: updated.id }, include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } });
|
||||
}
|
||||
|
||||
/** Removes a ticket option nobody has bought (and never the event's only main ticket). */
|
||||
export async function deleteOption(optionId: string) {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!option) return fail(404, 'Event option not found');
|
||||
await openEvent(option.eventId);
|
||||
if (option.isMainTicket) {
|
||||
const others = await prisma.eventOption.count({ where: { eventId: option.eventId, isMainTicket: true, NOT: { id: option.id } } });
|
||||
if (others === 0) fail(400, 'Cannot delete the only main ticket option');
|
||||
}
|
||||
if (await prisma.registrationOption.count({ where: { eventOptionId: optionId } }) > 0) fail(400, 'Cannot delete event option that has registrations');
|
||||
await prisma.eventOption.delete({ where: { id: optionId } });
|
||||
}
|
||||
|
||||
export type VariantInput = { name?: string; price?: number | string | null; stockLimit?: number | string; order?: number | string; staffPrice?: number | string | null };
|
||||
|
||||
export async function createVariant(optionId: string, input: VariantInput) {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!option) return fail(404, 'Event option not found');
|
||||
await openEvent(option.eventId);
|
||||
if (!input.name) fail(400, 'Variant name is required');
|
||||
return prisma.optionVariant.create({
|
||||
data: {
|
||||
id: uuidv4(), eventOptionId: optionId, name: String(input.name),
|
||||
price: input.price !== undefined && input.price !== null && input.price !== '' ? parseFloat(String(input.price)) : null,
|
||||
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0,
|
||||
staffPrice: parseStaffPrice(input.staffPrice),
|
||||
order: input.order !== undefined ? parseInt(String(input.order), 10) : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateVariant(variantId: string, input: VariantInput) {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) return fail(404, 'Variant not found');
|
||||
await openEvent(variant.eventOption.eventId);
|
||||
return prisma.optionVariant.update({
|
||||
where: { id: variantId },
|
||||
data: {
|
||||
name: input.name !== undefined ? String(input.name) : variant.name,
|
||||
staffPrice: parseStaffPrice(input.staffPrice, variant.staffPrice),
|
||||
price: input.price !== undefined ? (input.price === null || input.price === '' ? null : parseFloat(String(input.price))) : variant.price,
|
||||
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : variant.stockLimit,
|
||||
order: input.order !== undefined ? parseInt(String(input.order), 10) : variant.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes a variant nobody has bought. */
|
||||
export async function deleteVariant(variantId: string) {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) return fail(404, 'Variant not found');
|
||||
await openEvent(variant.eventOption.eventId);
|
||||
if (await prisma.registrationOption.count({ where: { variantId } }) > 0) fail(400, 'Cannot delete variant with existing registrations');
|
||||
await prisma.optionVariant.delete({ where: { id: variantId } });
|
||||
}
|
||||
|
||||
// ─── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAttachments(eventId: string, urlOf: UrlOf) {
|
||||
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
|
||||
return items.map(att => ({ ...att, url: urlOf(att.url) }));
|
||||
}
|
||||
|
||||
/** Records a file multer has already saved in ATTACHMENTS_DIR. */
|
||||
export async function recordAttachment(eventId: string, file: { originalname: string; filename: string; mimetype: string; size: number }, urlOf: UrlOf) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) {
|
||||
fs.promises.unlink(path.join(ATTACHMENTS_DIR, file.filename)).catch(() => {});
|
||||
return fail(404, 'Event not found');
|
||||
}
|
||||
const created = await prisma.eventAttachment.create({
|
||||
data: { id: uuidv4(), eventId, originalName: file.originalname, filename: file.filename, mimeType: file.mimetype, size: file.size, url: `/uploads/event-files/${file.filename}` },
|
||||
});
|
||||
return { ...created, url: urlOf(created.url) };
|
||||
}
|
||||
|
||||
export async function deleteAttachment(eventId: string, attachmentId: string) {
|
||||
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
|
||||
if (!att || att.eventId !== eventId) return fail(404, 'Attachment not found');
|
||||
// The file goes too (best effort; the record is what the site shows).
|
||||
await fs.promises.unlink(path.join(ATTACHMENTS_DIR, att.filename)).catch(() => {});
|
||||
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
|
||||
}
|
||||
|
||||
/** Admin diagnostics: whether the attachments table answers, and which database it is (no secrets). */
|
||||
export async function attachmentsStatus() {
|
||||
let tableQueryable = false;
|
||||
let probeError: string | null = null;
|
||||
try {
|
||||
await prisma.eventAttachment.findFirst({});
|
||||
tableQueryable = true;
|
||||
} catch (e: any) {
|
||||
probeError = String(e?.message || e);
|
||||
}
|
||||
let database: Record<string, string | undefined> | null = null;
|
||||
try {
|
||||
const u = new URL(process.env.DATABASE_URL || '');
|
||||
database = { protocol: u.protocol.replace(':', ''), host: u.hostname, port: u.port || undefined, database: u.pathname.replace(/^\//, '') || undefined };
|
||||
} catch { /* not a URL */ }
|
||||
return { prismaClientHasEventAttachmentModel: true, databaseTableQueryable: tableQueryable, probeError, database };
|
||||
}
|
||||
|
||||
/** Imports attachments still listed in old JSON manifests on disk into the database. */
|
||||
export async function syncAttachmentManifests(opts: { dryRun: boolean; remove: boolean }) {
|
||||
const { syncManifestsToDb } = require('../utils/attachmentsSync') as { syncManifestsToDb(db: unknown, o: { dryRun: boolean; removeManifestAfterImport: boolean }): Promise<Record<string, unknown>> };
|
||||
return { ...opts, ...(await syncManifestsToDb(prisma, { dryRun: opts.dryRun, removeManifestAfterImport: opts.remove })) };
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Setting events up (1.51.7): create, edit, deactivate, the staff list, notification recipients
|
||||
* and copying set-up from another event. Moved from eventController.js so /api/events (v1) and
|
||||
* /api/v2/events share it.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
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 */
|
||||
type CampusScope = object | null;
|
||||
const sessionsUtil = require('../utils/sessions') as {
|
||||
sessionError(message: string): Error;
|
||||
normalizeReminders(v: unknown): unknown;
|
||||
normalizeCertificateConfig(v: unknown): unknown;
|
||||
normalizeSessionsInput(v: unknown): Array<{ cancelled?: boolean } & Record<string, unknown>>;
|
||||
scheduleBounds(sessions: unknown[]): { startDate: Date; endDate: Date };
|
||||
forgetSchedule(eventId: string): void;
|
||||
syncEventSessions(tx: any, eventId: string, sessions: unknown[]): Promise<{ sessions: Array<{ id: string; order: number; cancelledAt: Date | null }>; changes: Record<string, number> }>;
|
||||
activeSessions(sessions: any[]): any[];
|
||||
registrationClosesAt(ev: any, sessions: any[]): Date;
|
||||
wholeCourseClosesAt(ev: any, sessions: any[]): Date;
|
||||
lateInfo(ev: any, sessions: any[], now: Date): unknown;
|
||||
};
|
||||
const campuses = require('../utils/campuses') as {
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
campusForEvent(campusId: unknown, opts: { enabled: boolean; currentCampusId?: string | null }): Promise<{ campusId?: string | null; error?: string }>;
|
||||
campusWhere(filter: string, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
|
||||
resolveCampusFilter(value: unknown): Promise<string>;
|
||||
EVENT_CAMPUS_SELECT: Record<string, true>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as {
|
||||
canManage(scope: object, campusId: string | null): boolean;
|
||||
eventWhere(scope: CampusScope): object;
|
||||
withScope(where: object, scopeWhere: object): object;
|
||||
};
|
||||
const seriesUtil = require('../utils/series') as { seriesForEvent(seriesId: unknown): Promise<string | null | undefined> };
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
/** The caller's campus scope (utils/campusScope.scopeFor) and IP, for checks and the audit log. */
|
||||
export type Ctx = { scope: CampusScope; ip?: string };
|
||||
/** An event body as the event wizard sends it (v1 and v2 take the same fields). */
|
||||
export type EventInput = Record<string, any>;
|
||||
|
||||
export const SERIES_SELECT = { select: { id: true, name: true, slug: true } } as const;
|
||||
export const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' as const }, include: { _count: { select: { usages: true } } } };
|
||||
|
||||
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
|
||||
export function assertCampusAllowed(scope: CampusScope, campusId: string | null): void {
|
||||
if (scope && !campusScope.canManage(scope, campusId)) {
|
||||
fail(403, campusId == null
|
||||
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
|
||||
: 'You can only put events at your own campuses.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the multi-day schedule to an event for API responses: sessions (cancelled ones flagged),
|
||||
* when registration closes, the next session, and late-joiner pricing. `bookings` (Map
|
||||
* sessionId → people booked) adds `booked` and `placesLeft` per session.
|
||||
*/
|
||||
export function withSchedule<E extends { sessions?: any[]; scheduleType?: string }>(ev: E, bookings: Map<string, number> | null = null) {
|
||||
const sessions = (ev.sessions || []).map(({ _count, ...s }: any) => ({
|
||||
...s,
|
||||
cancelled: !!s.cancelledAt,
|
||||
hasCheckIns: (_count?.usages || 0) > 0,
|
||||
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
|
||||
}));
|
||||
const active = sessionsUtil.activeSessions(sessions);
|
||||
const now = new Date();
|
||||
return {
|
||||
...ev,
|
||||
sessions,
|
||||
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
|
||||
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
|
||||
nextSession: ev.scheduleType === 'sessions' ? active.find((s: any) => new Date(s.endsAt) > now) || null : null,
|
||||
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The schedule/attendance fields from a create/update body. `current` is the saved event on update. */
|
||||
export function scheduleData(body: EventInput, current: Record<string, any> = {}): Record<string, any> {
|
||||
const has = (k: string) => body[k] !== undefined;
|
||||
const data: Record<string, any> = {};
|
||||
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
|
||||
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
|
||||
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
|
||||
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
|
||||
}
|
||||
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
|
||||
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
|
||||
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
|
||||
if (has('lateFixedPrice')) {
|
||||
const n = parseFloat(body.lateFixedPrice);
|
||||
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
|
||||
}
|
||||
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
|
||||
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
|
||||
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
|
||||
const latePricing = data.latePricing ?? current.latePricing;
|
||||
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
|
||||
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
|
||||
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the session list and the "late joining until" session, inside the caller's transaction
|
||||
* so a refused change saves nothing. Returns the change counts, or null when nothing was sent.
|
||||
*/
|
||||
export async function saveSchedule(tx: Prisma.TransactionClient, eventId: string, body: EventInput): Promise<Record<string, number> | null> {
|
||||
sessionsUtil.forgetSchedule(eventId);
|
||||
if (body.scheduleType === 'single') {
|
||||
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
|
||||
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
|
||||
await tx.eventSession.deleteMany({ where: { eventId } });
|
||||
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(body.sessions)) return null;
|
||||
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
|
||||
if (body.lateJoinUntilSessionIndex !== undefined) {
|
||||
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
|
||||
const ordered = [...sessions].sort((a, b) => a.order - b.order);
|
||||
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
|
||||
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const yes = (v: unknown) => v === true || v === 'true';
|
||||
const notNo = (v: unknown) => !(v === false || v === 'false');
|
||||
|
||||
/** Replaces the event's attendee form with the one sent (`{ isRequired, fields[] }`). */
|
||||
async function saveForm(eventId: string, formInput: any): Promise<void> {
|
||||
if (!formInput) return;
|
||||
try {
|
||||
let form = await prisma.eventForm.findUnique({ where: { eventId } });
|
||||
if (!form) form = await prisma.eventForm.create({ data: { id: uuidv4(), eventId, isRequired: !!formInput.isRequired } });
|
||||
else await prisma.eventForm.update({ where: { id: form.id }, data: { isRequired: !!formInput.isRequired } });
|
||||
await prisma.eventFormField.deleteMany({ where: { formId: form.id } });
|
||||
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fields[i];
|
||||
if (!f || !f.type || !f.label) continue;
|
||||
await prisma.eventFormField.create({
|
||||
data: {
|
||||
id: uuidv4(), formId: form.id, type: f.type, label: String(f.label), isRequired: !!f.isRequired,
|
||||
order: typeof f.order === 'number' ? f.order : i, helpText: f.helpText || null, options: f.options || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[events] Failed to save form/fields:', e?.message || e);
|
||||
fail(400, 'Failed to save event form/fields');
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns a helper's plain Error (session rules, series, campus) into a 400, like v1 did. */
|
||||
async function asClientErrors<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 || 'The event could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A new event, with a "Main Ticket" at the event price (unless it's contact-only), its sessions
|
||||
* and its attendee form. A campus-scoped supervisor must put it at one of their campuses.
|
||||
*/
|
||||
export function createEvent(body: EventInput, actor: Actor, ctx: Ctx) {
|
||||
return asClientErrors(async () => {
|
||||
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled() });
|
||||
if (campusPick.error) fail(400, campusPick.error);
|
||||
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
|
||||
assertCampusAllowed(ctx.scope, campusPick.campusId === undefined ? null : campusPick.campusId);
|
||||
const seriesId = await seriesUtil.seriesForEvent(body.seriesId);
|
||||
|
||||
const data: Record<string, any> = {
|
||||
id: uuidv4(),
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
startDate: new Date(body.startDate),
|
||||
endDate: new Date(body.endDate),
|
||||
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
|
||||
goLiveAt: body.goLiveAt ? new Date(body.goLiveAt) : undefined,
|
||||
price: parseFloat(body.price),
|
||||
picture: body.picture,
|
||||
updatedAt: new Date(),
|
||||
createdById: actor.id,
|
||||
redirectUrl: body.redirectUrl,
|
||||
isHidden: yes(body.isHidden),
|
||||
requiresAuth: notNo(body.requiresAuth),
|
||||
requiresRegistration: notNo(body.requiresRegistration),
|
||||
// A youth event needs names (with dates of birth), so it always takes them.
|
||||
allowNamedAttendees: yes(body.allowNamedAttendees) || body.requiresParentalConsent === true,
|
||||
requiresParentalConsent: body.requiresParentalConsent === true,
|
||||
consentText: body.consentText ? String(body.consentText) : null,
|
||||
consentCollectMedical: body.consentCollectMedical === true,
|
||||
consentCollectEmergency: body.consentCollectEmergency === true,
|
||||
consentCollectPhoto: body.consentCollectPhoto === true,
|
||||
waitlistEnabled: body.waitlistEnabled === true,
|
||||
allowTicketTransfer: body.allowTicketTransfer !== false,
|
||||
collectGender: body.collectGender === true,
|
||||
allowSponsorships: body.allowSponsorships === true,
|
||||
sponsorshipGoalSpots: body.sponsorshipGoalSpots ? Math.max(1, parseInt(body.sponsorshipGoalSpots, 10) || 0) || null : null,
|
||||
showSponsorshipProgress: body.showSponsorshipProgress !== false,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(body.waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: body.waitlistPartialOffers !== false,
|
||||
contactName: body.contactName || null,
|
||||
contactPhone: body.contactPhone || null,
|
||||
contactEmail: body.contactEmail || null,
|
||||
location: body.location || null,
|
||||
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesId ? { seriesId } : {}),
|
||||
...scheduleData(body),
|
||||
};
|
||||
if (data.scheduleType === 'sessions') {
|
||||
// Check the sessions before anything is saved; the dates come from them.
|
||||
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(body.sessions).filter(s => !s.cancelled));
|
||||
data.startDate = bounds.startDate;
|
||||
data.endDate = bounds.endDate;
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({ data: data as Prisma.EventUncheckedCreateInput });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'event_created', targetType: 'Event', targetId: event.id,
|
||||
metadata: { title: event.title, ...(body.clonedFromId ? { clonedFromId: String(body.clonedFromId) } : {}) }, ip: ctx.ip,
|
||||
});
|
||||
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, body));
|
||||
|
||||
// A main ticket at the event price — contact-only events have nothing to book.
|
||||
if (data.requiresRegistration !== false) {
|
||||
await prisma.eventOption.create({ data: { id: uuidv4(), eventId: event.id, name: 'Main Ticket', price: event.price || 0, isMainTicket: true } })
|
||||
.catch((e: any) => console.warn('[events] Failed to auto-create main ticket option:', e?.message || e));
|
||||
}
|
||||
await saveForm(event.id, body.form);
|
||||
|
||||
return withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE, eventOptions: true } }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an event: only the fields sent change. Its sessions save in the same transaction (a
|
||||
* refused schedule change saves nothing); a changed consent text starts a new version; moving it
|
||||
* to another series lets that series' followers hear about it. Closed (cashed-up) events can't
|
||||
* change.
|
||||
*/
|
||||
export function updateEvent(eventId: string, body: EventInput, actor: Actor, ctx: Ctx) {
|
||||
return asClientErrors(async () => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
// A closed (cashed-up) event's pricing/structure must not change under the reconciled totals.
|
||||
await assertEventOpen(eventId);
|
||||
|
||||
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled(), currentCampusId: event.campusId });
|
||||
if (campusPick.error) fail(400, campusPick.error);
|
||||
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
|
||||
const seriesPick = await seriesUtil.seriesForEvent(body.seriesId);
|
||||
// Moved into another series: that series' followers hear about it (once it's live).
|
||||
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
|
||||
if (campusChanged) assertCampusAllowed(ctx.scope, campusPick.campusId ?? null);
|
||||
|
||||
const keep = <K extends keyof typeof event>(k: K, v: unknown, map: (x: any) => unknown = x => x) => (v !== undefined ? map(v) : event[k]);
|
||||
const requiresParentalConsent = body.requiresParentalConsent ?? event.requiresParentalConsent;
|
||||
const data: Record<string, any> = {
|
||||
title: body.title || event.title,
|
||||
description: keep('description', body.description),
|
||||
startDate: body.startDate ? new Date(body.startDate) : event.startDate,
|
||||
endDate: body.endDate ? new Date(body.endDate) : event.endDate,
|
||||
registrationDeadline: keep('registrationDeadline', body.registrationDeadline, v => (v ? new Date(v) : null)),
|
||||
goLiveAt: body.goLiveAt !== undefined ? (body.goLiveAt ? new Date(body.goLiveAt) : new Date()) : (event.goLiveAt || undefined),
|
||||
price: body.price ? parseFloat(body.price) : event.price,
|
||||
picture: keep('picture', body.picture),
|
||||
isActive: keep('isActive', body.isActive),
|
||||
isHidden: body.isHidden !== undefined ? yes(body.isHidden) : (event.isHidden ?? false),
|
||||
requiresAuth: body.requiresAuth !== undefined ? notNo(body.requiresAuth) : (event.requiresAuth ?? true),
|
||||
requiresRegistration: body.requiresRegistration !== undefined ? notNo(body.requiresRegistration) : (event.requiresRegistration ?? true),
|
||||
allowNamedAttendees: requiresParentalConsent === true ? true : (body.allowNamedAttendees !== undefined ? yes(body.allowNamedAttendees) : event.allowNamedAttendees),
|
||||
requiresParentalConsent: keep('requiresParentalConsent', body.requiresParentalConsent, v => v === true),
|
||||
consentCollectMedical: keep('consentCollectMedical', body.consentCollectMedical, v => v === true),
|
||||
consentCollectEmergency: keep('consentCollectEmergency', body.consentCollectEmergency, v => v === true),
|
||||
consentCollectPhoto: keep('consentCollectPhoto', body.consentCollectPhoto, v => v === true),
|
||||
waitlistEnabled: keep('waitlistEnabled', body.waitlistEnabled, v => v === true),
|
||||
allowTicketTransfer: keep('allowTicketTransfer', body.allowTicketTransfer, v => v !== false),
|
||||
collectGender: keep('collectGender', body.collectGender, v => v === true),
|
||||
allowSponsorships: keep('allowSponsorships', body.allowSponsorships, v => v === true),
|
||||
sponsorshipGoalSpots: keep('sponsorshipGoalSpots', body.sponsorshipGoalSpots, v => (v ? Math.max(1, parseInt(v, 10) || 0) || null : null)),
|
||||
showSponsorshipProgress: keep('showSponsorshipProgress', body.showSponsorshipProgress, v => v !== false),
|
||||
waitlistOfferHours: keep('waitlistOfferHours', body.waitlistOfferHours, v => Math.min(168, Math.max(1, parseInt(v, 10) || 24))),
|
||||
waitlistPartialOffers: keep('waitlistPartialOffers', body.waitlistPartialOffers, v => v !== false),
|
||||
// Editing the consent text starts a new version; parents who already agreed keep their
|
||||
// record of the old one (supervisors can re-request consent).
|
||||
...(body.consentText !== undefined && String(body.consentText || '') !== String(event.consentText || '')
|
||||
? { consentText: body.consentText ? String(body.consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
|
||||
: {}),
|
||||
contactName: keep('contactName', body.contactName, v => v || null),
|
||||
contactPhone: keep('contactPhone', body.contactPhone, v => v || null),
|
||||
contactEmail: keep('contactEmail', body.contactEmail, v => v || null),
|
||||
location: keep('location', body.location, v => v || null),
|
||||
...(campusChanged ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: keep('redirectUrl', body.redirectUrl),
|
||||
...scheduleData(body, event),
|
||||
};
|
||||
// A multi-session event's dates come from its sessions (set by saveSchedule).
|
||||
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
|
||||
data.startDate = event.startDate;
|
||||
data.endDate = event.endDate;
|
||||
if (Array.isArray(body.sessions)) sessionsUtil.normalizeSessionsInput(body.sessions);
|
||||
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
|
||||
}
|
||||
|
||||
// Event fields and sessions save together: a refused schedule change saves nothing.
|
||||
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : body;
|
||||
let sessionChanges: Record<string, number> | null = null;
|
||||
const saved = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.event.update({ where: { id: eventId }, data });
|
||||
sessionChanges = updated.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single' ? await saveSchedule(tx, updated.id, scheduleBody) : null;
|
||||
return updated;
|
||||
});
|
||||
sessionsUtil.forgetSchedule(saved.id);
|
||||
if (campusChanged) {
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_campus_changed', targetType: 'Event', targetId: saved.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: ctx.ip });
|
||||
}
|
||||
const changes = sessionChanges as Record<string, number> | null;
|
||||
if (changes && Object.values(changes).some(n => n > 0)) {
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_sessions_changed', targetType: 'Event', targetId: saved.id, metadata: changes, ip: ctx.ip });
|
||||
}
|
||||
await saveForm(saved.id, body.form);
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_updated', targetType: 'Event', targetId: eventId, metadata: { changedKeys: Object.keys(body || {}) }, ip: ctx.ip });
|
||||
|
||||
const fresh = withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: saved.id }, include: { sessions: SESSIONS_INCLUDE } }));
|
||||
return { ...fresh, sessionChanges: changes };
|
||||
});
|
||||
}
|
||||
|
||||
/** "Deleting" an event deactivates it (registrations, payments and tickets stay). Admins only. */
|
||||
export async function deactivateEvent(eventId: string, actor: Actor, ctx: Ctx) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
const updated = await prisma.event.update({ where: { id: eventId }, data: { isActive: false, updatedAt: new Date() } });
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_deleted', targetType: 'Event', targetId: eventId, ip: ctx.ip });
|
||||
return updated;
|
||||
}
|
||||
|
||||
export type ManagedListOptions = { includePast?: boolean; includeInactive?: boolean; excludeClosed?: boolean; campus?: string };
|
||||
|
||||
/**
|
||||
* Every event staff may see (hidden ones too) — upcoming by default; past, inactive and
|
||||
* closed (cashed-up) on request; limited to their campuses when they're campus-scoped. With
|
||||
* ticket options (tiers, variants), the attendee form, sessions, campus and series.
|
||||
*/
|
||||
export async function listManagedEvents(opts: ManagedListOptions, ctx: Ctx) {
|
||||
const where: Record<string, any> = {};
|
||||
if (!opts.includeInactive) where.isActive = true;
|
||||
if (!opts.includePast) where.endDate = { gte: new Date() };
|
||||
if (opts.excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
|
||||
const enabled = await campuses.campusesEnabled();
|
||||
Object.assign(where, campuses.campusWhere(enabled ? await campuses.resolveCampusFilter(opts.campus) : '', { enabled }));
|
||||
const scoped = campusScope.withScope(where, campusScope.eventWhere(ctx.scope));
|
||||
const events = await prisma.event.findMany({
|
||||
where: scoped,
|
||||
include: {
|
||||
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: campuses.EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
return events.map(ev => withSchedule(ev));
|
||||
}
|
||||
|
||||
const RECIPIENT_SELECT = { id: true, name: true, email: true, role: true } as const;
|
||||
|
||||
/** Who gets this event's registration, payment and daily-summary emails. */
|
||||
export async function getNotifyRecipients(eventId: string) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { notifyRecipients: { select: RECIPIENT_SELECT } } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event.notifyRecipients;
|
||||
}
|
||||
|
||||
export async function setNotifyRecipients(eventId: string, userIds: string[]) {
|
||||
if (!Array.isArray(userIds)) fail(400, 'userIds must be an array');
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
const updated = await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
|
||||
include: { notifyRecipients: { select: RECIPIENT_SELECT } },
|
||||
});
|
||||
return updated.notifyRecipients;
|
||||
}
|
||||
|
||||
/** Copies allocation groups (and, for admins, planned costs) from another event into this one. */
|
||||
export async function copySetupFrom(eventId: string, fromEventId: string, actor: Actor) {
|
||||
if (!fromEventId) fail(400, 'fromEventId is required');
|
||||
const { copyEventSetup } = require('../utils/eventClone') as { copyEventSetup(from: string, to: string, opts: { withCosts: boolean }): Promise<unknown> };
|
||||
return copyEventSetup(String(fromEventId), eventId, { withCosts: actor.role === 'admin' });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Paying a registration online (1.51.1). Moved from paymentController.createCheckout so
|
||||
* POST /api/payments/checkout (v1) and POST /api/v2/registrations/:id/checkout 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 payments = require('../payments') as { MIN_ONLINE_AMOUNT: number };
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts') as {
|
||||
createRegistrationCheckout(registrationId: string, userId: string, opts: { successUrl?: string; cancelUrl?: string; failureUrl?: string; donationAmount?: number; amount?: number | null; provider?: string | null }): Promise<{
|
||||
checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string };
|
||||
}>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
refreshPricingForRegistration(id: string): Promise<{ changed: boolean }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
|
||||
export type CheckoutOptions = {
|
||||
/** Pay only this much of the balance (staff taking part payments). */
|
||||
amount?: number | null;
|
||||
/** An optional donation added on top (the register form's "Donation amount"). */
|
||||
donationAmount?: number | null;
|
||||
provider?: string | null;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
failureUrl?: string;
|
||||
};
|
||||
|
||||
export type CheckoutResult =
|
||||
| { priceUpdated: true; newTotal: number; message: string }
|
||||
| { priceUpdated?: false; checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string } };
|
||||
|
||||
/**
|
||||
* Starts an online payment for what's owed on a registration (the owner, or a supervisor or
|
||||
* admin). If an early-bird price ran out since it was made, nothing is charged: the caller is
|
||||
* told the new total to show first.
|
||||
*/
|
||||
export async function startRegistrationCheckout(registrationId: string, actor: { id: string; role: Role }, opts: CheckoutOptions = {}): Promise<CheckoutResult> {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const staffSide = actor.role === 'admin' || actor.role === 'supervisor';
|
||||
if (reg.userId !== actor.id && !staffSide) fail(403, 'Not authorized to create checkout for this registration');
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (!staffSide && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed } = await pricing.refreshPricingForRegistration(registrationId);
|
||||
if (changed) {
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const newTotal = pricing.computeRegistrationTotalDue(fresh, new Date());
|
||||
const totalPaid = fresh.payments.reduce((s, p) => s + p.amount, 0);
|
||||
return {
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.',
|
||||
};
|
||||
}
|
||||
|
||||
const urls = { successUrl: opts.successUrl, cancelUrl: opts.cancelUrl, failureUrl: opts.failureUrl, provider: opts.provider ?? null };
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (opts.amount !== undefined && opts.amount !== null && !isNaN(Number(opts.amount))) {
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, amount: Number(opts.amount) });
|
||||
}
|
||||
|
||||
let donation = 0;
|
||||
if (opts.donationAmount !== undefined && opts.donationAmount !== null) {
|
||||
donation = Math.round(Number(opts.donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) fail(400, 'Invalid donation amount');
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) fail(400, `Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
||||
}
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, donationAmount: donation });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Making and cancelling registrations (1.51.1). Moved here from registrationController.js so
|
||||
* POST /api/registrations (v1) and POST /api/v2/registrations run the same code. Errors are
|
||||
* ServiceErrors carrying their HTTP status.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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 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 }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
attachComputedTotals<T>(registration: T): T & { totalDueComputed: number };
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
buildCouponLines(registration: any): any[];
|
||||
previewCoupon(args: { code: string; eventId: string; user: any; lines: any[] }): Promise<unknown>;
|
||||
applyCouponToRegistration(args: { registrationId: string; code: string }): Promise<unknown>;
|
||||
recalculateRegistrationDiscount(registrationId: string): Promise<number>;
|
||||
releaseRegistrationCoupons(registrationId: string): Promise<unknown>;
|
||||
};
|
||||
const attendeesUtil = require('../utils/attendees') as {
|
||||
lineKey(eventOptionId: string, variantId?: string | null): string;
|
||||
validateAttendees(event: any, places: Record<string, number>, attendees: unknown, existing: Record<string, number>): any[];
|
||||
createAttendees(registrationId: string, attendees: any[]): Promise<unknown>;
|
||||
namedPlacesByKey(registrationId: string): Promise<Record<string, number>>;
|
||||
};
|
||||
const { findAccountByContact } = require('../utils/accountMatch') as { findAccountByContact(c: { email?: string; phone?: string }): Promise<{ user: { id: string } | null; conflict?: boolean }> };
|
||||
const { ensureConsentRequests } = require('../utils/consent') as { ensureConsentRequests(registrationId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object, opts?: { excludeEntryId?: string | null }): Promise<number> };
|
||||
const waitlist = require('../utils/waitlist') as { findOfferByToken(token: string): Promise<any>; queueWaitlist(eventId: string): void };
|
||||
const { reservedOptionIds } = require('../utils/volunteers') as { reservedOptionIds(event: any): string[] };
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles') as { BUNDLE_APPS_INCLUDE: any };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
registrationClosesAt(event: any, sessions: any[]): Date;
|
||||
wholeCourseClosesAt(event: any, sessions: any[]): Date;
|
||||
expandPickLines(event: any, options: any[], opts: { now: Date }): any[];
|
||||
assertSessionCapacity(event: any, lines: any[], registrationId?: string, opts?: { excludeEntryId?: string }): Promise<void>;
|
||||
mergeSessionIds(a: string[] | null | undefined, b: string[] | null | undefined): string[];
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string, res?: unknown): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Role = 'admin' | 'supervisor' | 'staff' | 'user';
|
||||
export type Actor = { id: string; role: Role; email?: string } | null;
|
||||
|
||||
export const TICKET_DELIVERY = ['payer', 'each'] as const;
|
||||
export const isStaffRole = (role: string | undefined | null): boolean => ['admin', 'supervisor', 'staff'].includes(String(role));
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
export const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' as const },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' as const }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
export const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
export function queueConsentRequests(registrationId: string): void {
|
||||
ensureConsentRequests(registrationId).catch((e: any) => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
type Line = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; quantity: number | string };
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
export function placesAfter(existingLines: Line[] | undefined, requested: Line[] | undefined): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = attendeesUtil.lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
export function pendingCouponLines(event: { eventOptions: { id: string }[] }, resolvedOptions: any[]): any[] {
|
||||
return coupons.buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
export async function priceRequestedOption(eventOption: any, variantId: string | null, qty: number, buyer: any): Promise<{ priceSnapshot: number; appliedTierId: string | null }> {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await pricing.resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await pricing.resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch {
|
||||
const variant = variantId ? (eventOption.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
*/
|
||||
export async function checkOptionStock(option: { id: string; stockLimit: number }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/** Check stock availability for a specific variant (same rules as checkOptionStock). */
|
||||
export async function checkVariantStock(variant: { id: string; eventOptionId: string; stockLimit: number | null }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
export type RequestedOption = { eventOptionId: string; quantity: number; variantId?: string | null; sessionIds?: string[] };
|
||||
export type CreateRegistrationInput = {
|
||||
eventId: string;
|
||||
options: RequestedOption[];
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestPhone?: string;
|
||||
couponCode?: string;
|
||||
attendees?: unknown[];
|
||||
ticketDelivery?: string;
|
||||
waitlistToken?: string;
|
||||
analyticsSessionId?: string;
|
||||
};
|
||||
|
||||
const FULL_INCLUDE = {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Registers someone for an event (online, a guest for a free event, or with a waitlist link):
|
||||
* checks the event is open, prices each line (early-bird, variant, staff price), checks stock
|
||||
* and session places, adds to an existing registration for the same person and event, applies
|
||||
* bundle deals and a coupon, and issues tickets straight away when nothing is owed.
|
||||
*/
|
||||
export async function createRegistration(input: CreateRegistrationInput, actor: Actor) {
|
||||
try {
|
||||
return await create(input, actor);
|
||||
} catch (e: any) {
|
||||
// The helpers (coupons, attendee names, session places…) throw plain Errors written for
|
||||
// people; like v1, those are the caller's problem (400) unless they carry a status.
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateRegistrationInput, actor: Actor) {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = input;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!event.isActive) return fail(400, 'Event is not active');
|
||||
if (event.cashupStatus === 'closed') return fail(400, 'This event is closed and no longer accepting registrations.');
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer: any = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await waitlist.findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) fail(400, 'This waitlist link is not valid.');
|
||||
if (waitlistOffer.status !== 'offered') fail(400, `This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`);
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) fail(400, 'This waitlist offer has expired.');
|
||||
if (actor && actor.id !== waitlistOffer.userId) fail(403, 'This waitlist offer belongs to someone else.');
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
if (event.requiresAuth !== false && !actor && !waitlistOffer) fail(401, 'Authentication required for this event');
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!actor && !waitlistOffer) {
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) fail(400, 'Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId: string;
|
||||
if (actor) {
|
||||
userId = actor.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) return fail(400, 'Name and email are required for unauthenticated registration');
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) fail(409, 'This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
if (match.user) {
|
||||
userId = match.user.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: { id: uuidv4(), name: guestName, email: guestEmail, password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= sessions.registrationClosesAt(event, event.sessions)) fail(400, 'Registration is closed for this event');
|
||||
|
||||
if (!options || !Array.isArray(options) || options.length === 0) fail(400, 'At least one option must be selected');
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = sessions.expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= sessions.wholeCourseClosesAt(event, event.sessions)) {
|
||||
fail(400, 'Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await sessions.assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock. Options are independent of each
|
||||
// other, so resolve them concurrently. The kids check-in and volunteer tickets are handed
|
||||
// out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(actor?.role) ? [] : reservedOptionIds(event));
|
||||
const buyerFor = async () => actor || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option: any) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) fail(400, `Option with ID ${option.eventOptionId} not found for this event`);
|
||||
if (!option.quantity || option.quantity < 1) fail(400, 'Quantity must be at least 1');
|
||||
const qty = option.quantity;
|
||||
|
||||
// (A failing count doesn't block the sale — only a real shortage does.)
|
||||
const stockCheck = await checkOptionStock(eventOption!, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!stockCheck.available) fail(400, `"${eventOption!.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
|
||||
const variantId: string | null = option.variantId || null;
|
||||
if (variantId) {
|
||||
const variant = (eventOption!.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) return fail(400, `Variant not found for option "${eventOption!.name}"`);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!vStock.available) fail(400, `Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
}
|
||||
|
||||
// Variant-level tiers take priority, then option-level tiers, then base prices, then
|
||||
// the staff price for Staff and above.
|
||||
const { priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, await buyerFor());
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot: priceSnapshot ?? eventOption!.price };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
await coupons.previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true },
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = attendeesUtil.validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await attendeesUtil.namedPlacesByKey(existingReg.id) : {},
|
||||
);
|
||||
if (ticketDelivery !== undefined && !(TICKET_DELIVERY as readonly string[]).includes(ticketDelivery)) fail(400, "ticketDelivery must be 'payer' or 'each'");
|
||||
|
||||
let registrationId: string;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null),
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: { id: uuidv4(), registrationOptionId: existing.id, quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null },
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: sessions.mergeSessionIds(existing.sessionIds, opt.sessionIds) }) },
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)), 0);
|
||||
await prisma.registration.update({
|
||||
where: { id: existingReg.id },
|
||||
data: existingReg.status === 'paid' && newItemsCost > 0 ? { status: 'partial_paid', updatedAt: new Date() } : { updatedAt: new Date() },
|
||||
});
|
||||
registrationId = existingReg.id;
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
registrationId = uuidv4();
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: option.quantity, priceSnapshot: option.priceSnapshot, appliedTierId: option.appliedTierId || null }] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await attendeesUtil.createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery: ticketDelivery as 'payer' | 'each' } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError: string | null = null;
|
||||
await coupons.recalculateRegistrationDiscount(registrationId);
|
||||
if (couponCode) {
|
||||
try {
|
||||
await coupons.applyCouponToRegistration({ registrationId, code: couponCode });
|
||||
} catch (e: any) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
const load = () => prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { ...FULL_INCLUDE, couponRedemptions: ACTIVE_COUPONS_INCLUDE, bundleApplications: BUNDLE_APPS_INCLUDE, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
let registration = await load();
|
||||
|
||||
// Nothing owed: mark paid and issue tickets (unless a required form comes first).
|
||||
let emailTicketsAfter = false;
|
||||
try {
|
||||
if (pricing.computeRegistrationTotalDue(registration, new Date()) === 0) {
|
||||
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)) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registrationId);
|
||||
emailTicketsAfter = true;
|
||||
}
|
||||
registration = { ...registration, status: 'paid' };
|
||||
}
|
||||
} catch {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const notifications = require('../utils/notifications') as { sendRegistrationEmails(id: string): Promise<unknown>; sendRegistrationUpdatedEmails(id: string): Promise<unknown> };
|
||||
const ownerId = registration.userId;
|
||||
void (async () => {
|
||||
try {
|
||||
if (isNewRegistration) await notifications.sendRegistrationEmails(registrationId);
|
||||
else await notifications.sendRegistrationUpdatedEmails(registrationId);
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (emailTicketsAfter) {
|
||||
try {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: unknown, res: unknown): Promise<unknown> };
|
||||
const res: { status(): unknown; json(): void } = { status: () => res, json: () => {} };
|
||||
await emailTickets({ user: { id: ownerId }, body: { registrationId } }, res);
|
||||
} catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics') as { recordSubmit(reg: unknown, sessionId: string): Promise<unknown> };
|
||||
recordSubmit(registration, analyticsSessionId).catch((e: any) => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
return { registration: pricing.attachComputedTotals(registration), isNew: isNewRegistration, couponError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a registration: the owner (not after the event, not once anything is paid) or an
|
||||
* admin. Releases its coupon and offers its places to the waitlist.
|
||||
*/
|
||||
export async function cancelRegistration(registrationId: string, actor: { id: string; role: Role }, ip?: string) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { event: { select: { endDate: true } } },
|
||||
});
|
||||
if (!registration) return fail(404, 'Registration not found');
|
||||
|
||||
if (registration.userId !== actor.id && actor.role !== 'admin') fail(403, 'Not authorized to cancel this registration');
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit block)
|
||||
if (actor.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
try {
|
||||
await assertEventOpen(registration.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
if (registration.status === 'cancelled') fail(400, 'Registration is already cancelled');
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (actor.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({ where: { registrationId: registration.id, amount: { gt: 0 } } });
|
||||
if (paymentCount > 0) fail(403, 'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
|
||||
const updated = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'cancelled', updatedAt: new Date() } });
|
||||
await coupons.releaseRegistrationCoupons(registrationId);
|
||||
waitlist.queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== actor.id) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_cancelled', targetType: 'Registration', targetId: registrationId,
|
||||
metadata: { registrationOwnerId: registration.userId }, ip,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -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 } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Scanning sections and planned event costs (1.51.8). Moved from sectionController.js and
|
||||
* costController.js so v1 and /api/v2 share them.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const cashup = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown>; ALL_METHODS: string[] };
|
||||
|
||||
const openEvent = async (eventId: string) => {
|
||||
try {
|
||||
await cashup.assertEventOpen(eventId);
|
||||
} catch (e: any) {
|
||||
fail(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Sections (which ticket types may come in at which door) ─────────────────
|
||||
|
||||
const SECTION_INCLUDE = { allowedOptions: { include: { eventOption: true } } } as const;
|
||||
|
||||
/** Sections, for one event or every event the caller may see (`scopeWhere` from campus scope). */
|
||||
export function listSections(eventId: string | undefined, scopeWhere: object) {
|
||||
const where = { AND: [eventId && eventId !== 'all' ? { eventId } : {}, scopeWhere] };
|
||||
return prisma.section.findMany({ where, include: { ...SECTION_INCLUDE, event: { select: { id: true, title: true } } }, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
export async function createSection(input: { eventId?: string; name?: string; allowedOptionIds?: string[] }) {
|
||||
if (!input.eventId || !input.name) return fail(400, 'Event and section name are required');
|
||||
const event = await prisma.event.findUnique({ where: { id: input.eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
const section = await prisma.section.create({ data: { id: uuidv4(), eventId: input.eventId, name: String(input.name), updatedAt: new Date() } });
|
||||
if (Array.isArray(input.allowedOptionIds) && input.allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId: section.id, eventOptionId: optionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
return prisma.section.findUniqueOrThrow({ where: { id: section.id }, include: SECTION_INCLUDE });
|
||||
}
|
||||
|
||||
/** Renames a section and/or replaces its ticket types (when `allowedOptionIds` is sent). */
|
||||
export async function updateSection(sectionId: string, input: { name?: string; allowedOptionIds?: string[] }) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId } });
|
||||
if (!section) return fail(404, 'Section not found');
|
||||
await prisma.section.update({ where: { id: sectionId }, data: { name: input.name || section.name, updatedAt: new Date() } });
|
||||
if (Array.isArray(input.allowedOptionIds)) {
|
||||
await prisma.sectionOption.deleteMany({ where: { sectionId } });
|
||||
if (input.allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({ data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId, eventOptionId: optionId })) });
|
||||
}
|
||||
}
|
||||
return prisma.section.findUniqueOrThrow({ where: { id: sectionId }, include: SECTION_INCLUDE });
|
||||
}
|
||||
|
||||
export async function deleteSection(sectionId: string) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, select: { id: true } });
|
||||
if (!section) return fail(404, 'Section not found');
|
||||
await prisma.section.delete({ where: { id: sectionId } });
|
||||
}
|
||||
|
||||
// ─── Planned costs (for cashup and the profit report) ────────────────────────
|
||||
|
||||
const COST_INCLUDE = { eventOption: { select: { id: true, name: true } } } as const;
|
||||
|
||||
function paidFromMethod(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!cashup.ALL_METHODS.includes(String(value))) fail(400, "paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function listCosts(eventId: string) {
|
||||
return prisma.eventCost.findMany({ where: { eventId }, include: COST_INCLUDE, orderBy: { createdAt: 'asc' } });
|
||||
}
|
||||
|
||||
export type CostInput = { label?: string; costType?: string; amount?: number | string; eventOptionId?: string | null; notes?: string | null; paidFromMethod?: string | null };
|
||||
|
||||
/** A cost per event ("hall hire") or per ticket sold ("T-shirt per camper"). */
|
||||
export async function createCost(eventId: string, input: CostInput) {
|
||||
await openEvent(eventId);
|
||||
if (!input.label || !String(input.label).trim()) fail(400, 'Label is required');
|
||||
if (input.costType !== 'once_off' && input.costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
|
||||
const amount = parseFloat(String(input.amount));
|
||||
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
|
||||
if (input.costType === 'per_item') {
|
||||
if (!input.eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: input.eventOptionId! }, select: { eventId: true } });
|
||||
if (!option || option.eventId !== eventId) fail(404, 'Ticket type not found for this event');
|
||||
}
|
||||
return prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(), eventId, label: String(input.label).trim(), costType: input.costType as 'once_off' | 'per_item', amount,
|
||||
eventOptionId: input.costType === 'per_item' ? input.eventOptionId! : null,
|
||||
paidFromMethod: (paidFromMethod(input.paidFromMethod) ?? null) as any, notes: input.notes || null,
|
||||
},
|
||||
include: COST_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCost(costId: string, input: CostInput) {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: costId } });
|
||||
if (!existing) return fail(404, 'Cost not found');
|
||||
await openEvent(existing.eventId);
|
||||
const costType = input.costType !== undefined ? input.costType : existing.costType;
|
||||
if (costType !== 'once_off' && costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
|
||||
const eventOptionId = costType === 'per_item' ? (input.eventOptionId !== undefined ? input.eventOptionId : existing.eventOptionId) : null;
|
||||
if (costType === 'per_item') {
|
||||
if (!eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId! }, select: { eventId: true } });
|
||||
if (!option || option.eventId !== existing.eventId) fail(404, 'Ticket type not found for this event');
|
||||
}
|
||||
let amount = existing.amount;
|
||||
if (input.amount !== undefined) {
|
||||
amount = parseFloat(String(input.amount));
|
||||
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
|
||||
}
|
||||
const paid = paidFromMethod(input.paidFromMethod);
|
||||
return prisma.eventCost.update({
|
||||
where: { id: costId },
|
||||
data: {
|
||||
label: input.label !== undefined ? String(input.label).trim() : existing.label,
|
||||
costType: costType as 'once_off' | 'per_item',
|
||||
amount,
|
||||
eventOptionId,
|
||||
paidFromMethod: (paid !== undefined ? paid : existing.paidFromMethod) as any,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
},
|
||||
include: COST_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCost(costId: string) {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: costId }, select: { eventId: true } });
|
||||
if (!existing) return fail(404, 'Cost not found');
|
||||
await openEvent(existing.eventId);
|
||||
await prisma.eventCost.delete({ where: { id: costId } });
|
||||
}
|
||||
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
|
||||
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
|
||||
|
||||
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
|
||||
// includes that day. `search` matches the person (name or email), the target type or id,
|
||||
// or the IP.
|
||||
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (role) where.actorRole = role;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
if (from) where.createdAt.gte = startOf(from);
|
||||
if (to) {
|
||||
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
|
||||
else where.createdAt.lte = new Date(to);
|
||||
}
|
||||
}
|
||||
const q = String(search || '').trim().slice(0, 100);
|
||||
if (q) {
|
||||
const contains = { contains: q, mode: 'insensitive' };
|
||||
where.OR = [
|
||||
{ actor: { name: contains } },
|
||||
{ actor: { email: contains } },
|
||||
{ targetType: contains },
|
||||
{ targetId: { startsWith: q.replace(/^#/, '') } },
|
||||
{ ip: { startsWith: q } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
|
||||
const where = auditLogWhere(filters);
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
@@ -467,6 +467,10 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
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 };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
|
||||
* with one click; one per page can be their default (applied when the page opens). Stored per
|
||||
* person on the server, so they follow them to any device. Only their own are ever read or changed.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// The list pages that have saved filters, and who can open them.
|
||||
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
|
||||
const MAX_PER_PAGE = 30;
|
||||
const MAX_PARAMS_BYTES = 4000;
|
||||
|
||||
function filterError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function cleanPage(page) {
|
||||
const p = String(page || '');
|
||||
if (!PAGES[p]) throw filterError('Unknown list page');
|
||||
return p;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim().replace(/\s+/g, ' ');
|
||||
if (!n) throw filterError('Give the filter a name');
|
||||
return n.slice(0, 60);
|
||||
}
|
||||
|
||||
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
|
||||
function cleanParams(params) {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
|
||||
if (typeof v === 'string') out[k] = v.slice(0, 500);
|
||||
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
|
||||
else if (typeof v === 'boolean') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
|
||||
}
|
||||
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
|
||||
return out;
|
||||
}
|
||||
|
||||
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
|
||||
|
||||
async function list(userId, page) {
|
||||
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
|
||||
async function save(userId, { page, name, params, isDefault = false }) {
|
||||
const p = cleanPage(page);
|
||||
const n = cleanName(name);
|
||||
const data = cleanParams(params);
|
||||
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
|
||||
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
|
||||
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
|
||||
}
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
|
||||
const row = existing
|
||||
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
|
||||
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
|
||||
return view(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function own(userId, id) {
|
||||
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
|
||||
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Rename, or make (or stop being) the default. */
|
||||
async function update(userId, id, { name, isDefault }) {
|
||||
const row = await own(userId, id);
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
|
||||
try {
|
||||
return view(await tx.savedFilter.update({
|
||||
where: { id: row.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(userId, id) {
|
||||
const row = await own(userId, id);
|
||||
await prisma.savedFilter.delete({ where: { id: row.id } });
|
||||
}
|
||||
|
||||
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
|
||||
@@ -34,6 +34,48 @@ const EVENT_TYPES = [
|
||||
];
|
||||
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
|
||||
|
||||
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
|
||||
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
|
||||
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
|
||||
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
|
||||
const exampleRegistration = (status, amountPaid) => ({
|
||||
registration: {
|
||||
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
|
||||
seasonPassPurchaseId: null,
|
||||
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
|
||||
},
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const examplePayment = (amount) => ({
|
||||
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const EXAMPLES = {
|
||||
'registration.created': exampleRegistration('pending', 0),
|
||||
'registration.paid': exampleRegistration('paid', 1200),
|
||||
'registration.cancelled': exampleRegistration('cancelled', 0),
|
||||
'payment.received': examplePayment(1200),
|
||||
// Refunds are recorded as negative payments.
|
||||
'payment.refunded': examplePayment(-600),
|
||||
'ticket.checked_in': {
|
||||
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
|
||||
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
},
|
||||
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
|
||||
'event.published': {
|
||||
event: {
|
||||
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
|
||||
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
|
||||
campus: { name: 'North', slug: 'north' }, series: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
|
||||
|
||||
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
|
||||
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
|
||||
const KEEP_DAYS = 30;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
|
||||
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
|
||||
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
|
||||
*/
|
||||
|
||||
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
|
||||
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
|
||||
|
||||
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
|
||||
|
||||
const SUCCESSORS: Successor[] = [
|
||||
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
// v1's event detail is the full record (v2's /events/{id} is the public summary).
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/full` },
|
||||
// 1.51.8: the rest of the events area
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/by-alias\/([^/]+)$/, v2: m => `/api/v2/events/by-alias/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/options$/, v2: m => `/api/v2/events/${m[1]}/ticket-options` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/options\/([^/]+)\/variants$/, v2: m => `/api/v2/ticket-options/${m[1]}/variants` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/attachments\/status$/, v2: () => '/api/v2/attachments/status' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/attachments\/sync$/, v2: () => '/api/v2/attachments/sync' },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)\/attachments\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attachments/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
|
||||
{ method: 'POST', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
|
||||
{ method: 'PUT', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
|
||||
{ method: 'PUT', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/uploads\/(event-image|logo|favicon)$/, v2: m => `/api/v2/uploads/${m[1]}` },
|
||||
// 1.51.7: setting events up
|
||||
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
{ 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: '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' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
];
|
||||
|
||||
// GET /api/events/all isn't a single event.
|
||||
const NOT_AN_EVENT_ID = new Set(['all']);
|
||||
|
||||
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
|
||||
export function successorOf(method: string, path: string): string | null {
|
||||
for (const s of SUCCESSORS) {
|
||||
if (s.method !== method) continue;
|
||||
const m = path.match(s.v1);
|
||||
if (!m) continue;
|
||||
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
|
||||
return s.v2(m);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const deprecationHeaders: RequestHandler = (req, res, next) => {
|
||||
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
|
||||
if (successor) {
|
||||
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
|
||||
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* /api/v2 errors all look the same:
|
||||
* { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } }
|
||||
* `code` is stable (clients switch on it), `message` is for people, `fields` only on validation.
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'VALIDATION_FAILED'
|
||||
| 'UNAUTHENTICATED'
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'CONFLICT'
|
||||
| 'RATE_LIMITED'
|
||||
| 'READ_ONLY'
|
||||
| 'UNAVAILABLE'
|
||||
| 'INTERNAL';
|
||||
|
||||
export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
VALIDATION_FAILED: 400,
|
||||
UNAUTHENTICATED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
this.code = code;
|
||||
this.status = ERROR_STATUS[code];
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`);
|
||||
|
||||
/** "limit" or "items.0.quantity" → the first problem with it. */
|
||||
export function zodFields(err: ZodError): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const issue of err.issues) {
|
||||
const key = issue.path.length ? issue.path.join('.') : '_';
|
||||
if (!fields[key]) fields[key] = issue.message;
|
||||
}
|
||||
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', 503: 'UNAVAILABLE' };
|
||||
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
|
||||
|
||||
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
|
||||
|
||||
/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */
|
||||
export function toApiError(err: unknown, responseStatus: number): ApiError {
|
||||
if (err instanceof ApiError) return err;
|
||||
if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err));
|
||||
const e = (err || {}) as LegacyError;
|
||||
if (typeof e.code === 'string' && PRISMA_CODE[e.code]) {
|
||||
return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.');
|
||||
}
|
||||
// The shared v1 middleware (protect, staff…) sets res.status() and passes an Error;
|
||||
// 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) {
|
||||
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 } : {}), ...(apiErr.reason ? { reason: apiErr.reason } : {}) } });
|
||||
};
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
|
||||
|
||||
// v1-style codes from shared middleware, kept as the v2 error's `reason`.
|
||||
const READ_ONLY_REASONS = new Set(['IMPERSONATION_READ_ONLY', 'LICENCE_READ_ONLY']);
|
||||
|
||||
/**
|
||||
* Mounted in front of everything on /api/v2: middleware shared with v1 (rate limits, the
|
||||
* read-only guards, the auth checks, campus refusals) answers `{ message, code? }`; on v2 that
|
||||
* becomes `{ error: { code, message, reason? } }`, where `reason` is the specific old code
|
||||
* (e.g. TWO_FACTOR_REQUIRED, LICENCE_READ_ONLY, OTHER_CAMPUS).
|
||||
*/
|
||||
export const legacyErrorsToV2: RequestHandler = (_req, res, next) => {
|
||||
const json = res.json.bind(res);
|
||||
res.json = (body?: unknown) => {
|
||||
const b = body as { message?: unknown; code?: unknown; error?: unknown } | null;
|
||||
if (res.statusCode >= 400 && b && typeof b === 'object' && !('error' in b) && typeof b.message === 'string') {
|
||||
const reason = typeof b.code === 'string' ? b.code : undefined;
|
||||
const code: ErrorCode = reason && READ_ONLY_REASONS.has(reason) ? 'READ_ONLY' : STATUS_CODE[res.statusCode] || (res.statusCode >= 500 ? 'INTERNAL' : 'VALIDATION_FAILED');
|
||||
return json({ error: { code, message: b.message, ...(reason ? { reason } : {}) } });
|
||||
}
|
||||
return json(body);
|
||||
};
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import express, { Router } from 'express';
|
||||
import path from 'path';
|
||||
import { mount, routes } from './route';
|
||||
import { buildOpenApi } from './openapi';
|
||||
import { errorHandler, notFoundHandler } from './errors';
|
||||
import auth from '../middleware/authMiddleware';
|
||||
// Resources: importing one registers its routes.
|
||||
// eventsManage first: /events/manage must match before /events/:id.
|
||||
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/kidsVolunteers';
|
||||
import './resources/peopleMessaging';
|
||||
import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
import './resources/ai';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
/** /api/v2 — see route.ts for how endpoints are declared, openapi.ts for the docs. */
|
||||
export function v2Router(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// The API description is for the people building on it (admins), like /docs.
|
||||
router.get('/openapi.json', auth.protect, auth.admin, (_req, res) => {
|
||||
res.json(buildOpenApi({ version: pkg.version }));
|
||||
});
|
||||
|
||||
mount(router, routes);
|
||||
router.use(notFoundHandler);
|
||||
router.use(errorHandler);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* /docs/v2: Swagger UI (served from swagger-ui-dist, nothing from a CDN). The page itself is
|
||||
* public; it asks for an admin token and uses it to load openapi.json and to try requests.
|
||||
*/
|
||||
export function v2DocsRouter(): Router {
|
||||
const router = express.Router();
|
||||
const assets = path.dirname(require.resolve('swagger-ui-dist/package.json'));
|
||||
router.use('/assets', express.static(assets, { index: false, maxAge: '1d' }));
|
||||
router.get('/', (_req, res) => {
|
||||
res.type('html').send(DOCS_HTML);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
const DOCS_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cross Events API v2</title>
|
||||
<link rel="stylesheet" href="/docs/v2/assets/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
#login { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
|
||||
#login input { width: 100%; padding: .5rem; font: inherit; border: 1px solid #9ca3af; border-radius: 6px; box-sizing: border-box; }
|
||||
#login button { margin-top: .75rem; padding: .5rem 1rem; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
|
||||
#login .err { color: #b91c1c; margin-top: .5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="login" hidden>
|
||||
<h1>Cross Events API v2</h1>
|
||||
<p>The API reference is for admins. Paste your admin token (after signing in, it's under <code>hope_events_token</code> in the browser's local storage).</p>
|
||||
<label for="token">Admin token</label>
|
||||
<input id="token" autocomplete="off">
|
||||
<button id="go" type="button">Show the API</button>
|
||||
<p class="err" id="err" role="alert"></p>
|
||||
</main>
|
||||
<div id="ui"></div>
|
||||
<script src="/docs/v2/assets/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var token = params.get('token') || sessionStorage.getItem('v2docs_token') || localStorage.getItem('hope_events_token') || '';
|
||||
function show(t) {
|
||||
fetch('/api/v2/openapi.json', { headers: { Authorization: 'Bearer ' + t } }).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (b) { throw new Error((b.error && b.error.message) || r.statusText); });
|
||||
return r.json();
|
||||
}).then(function (spec) {
|
||||
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, docExpansion: 'none', filter: true });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
document.getElementById('err').textContent = t ? 'That token did not work: ' + e.message : '';
|
||||
});
|
||||
}
|
||||
document.getElementById('go').addEventListener('click', function () { show(document.getElementById('token').value.trim()); });
|
||||
show(token);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Typed doors into the JavaScript side of the backend. TypeScript infers loose types from the
|
||||
* .js files (the Prisma client is built by a function, helpers have defaulted options), so v2
|
||||
* code imports them from here instead. When a module is converted to TypeScript, its line here
|
||||
* goes and callers import it directly.
|
||||
*/
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
// The client in config/db.js only adds query hooks (webhooks), so its models are PrismaClient's.
|
||||
export const prisma = require('../config/db') as PrismaClient;
|
||||
|
||||
type CampusFilter = '' | 'all' | string;
|
||||
const campuses = require('../utils/campuses') as {
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
resolveCampusFilter(value: string | undefined): Promise<CampusFilter>;
|
||||
campusWhere(filter: CampusFilter, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
|
||||
};
|
||||
export const { campusesEnabled, resolveCampusFilter, campusWhere } = campuses;
|
||||
|
||||
export const { publicEventWhere } = require('../utils/series') as {
|
||||
publicEventWhere(now?: Date): { isActive: true; isHidden: false; goLiveAt: { lte: Date } };
|
||||
};
|
||||
|
||||
type OptionLike = { id: string; price: number; earlyBirdTiers?: unknown[] };
|
||||
export const { publicOptions } = require('../utils/volunteers') as {
|
||||
publicOptions<O extends { id: string }>(event: { eventOptions?: O[]; kidsOptionId?: string | null; volunteerOptionId?: string | null }): O[];
|
||||
};
|
||||
|
||||
export const { getEffectiveUnitPrice, computeRegistrationTotalDue } = require('../utils/pricing') as {
|
||||
getEffectiveUnitPrice(option: OptionLike, referenceTime: Date | null, atTime: Date): number;
|
||||
computeRegistrationTotalDue(registration: object, atTime: Date): number;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from 'zod';
|
||||
import { routes as allRoutes, type AnyRoute, type Access } from './route';
|
||||
import { ERROR_STATUS } from './errors';
|
||||
|
||||
/**
|
||||
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
|
||||
* always what the code does. Served at /api/v2/openapi.json, shown at /docs/v2.
|
||||
*/
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
const toSchema = (schema: z.ZodType, io: 'input' | 'output'): JsonSchema => {
|
||||
const { $schema: _drop, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const ACCESS_TEXT: Record<Access, string> = {
|
||||
public: 'Anyone.',
|
||||
optional: 'Anyone; signed-in staff may see more.',
|
||||
user: 'Any signed-in person.',
|
||||
staff: 'Staff, supervisors and admins.',
|
||||
supervisor: 'Supervisors and admins.',
|
||||
admin: 'Admins only.',
|
||||
};
|
||||
|
||||
const errorSchema = {
|
||||
type: 'object',
|
||||
required: ['error'],
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
required: ['code', 'message'],
|
||||
properties: {
|
||||
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
|
||||
message: { type: 'string' },
|
||||
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
|
||||
reason: { type: 'string', description: 'A more specific cause when there is one, e.g. TWO_FACTOR_REQUIRED, IMPERSONATION_ENDED, LICENCE_READ_ONLY, OTHER_CAMPUS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const errorResponse = (description: string) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } });
|
||||
|
||||
function parameters(def: AnyRoute) {
|
||||
const out: JsonSchema[] = [];
|
||||
const add = (schema: z.ZodType | undefined, where: 'path' | 'query') => {
|
||||
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 };
|
||||
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
|
||||
}
|
||||
};
|
||||
add(def.params, 'path');
|
||||
add(def.query, 'query');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
|
||||
const list = opts.list ?? allRoutes;
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const def of list) {
|
||||
const path = def.path.replace(/:(\w+)/g, '{$1}');
|
||||
const secured = def.access !== 'public' && def.access !== 'optional';
|
||||
paths[path] ||= {};
|
||||
paths[path][def.method] = {
|
||||
tags: [def.tag],
|
||||
summary: def.summary,
|
||||
description: [def.description, `**Who:** ${ACCESS_TEXT[def.access]}`].filter(Boolean).join('\n\n'),
|
||||
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
|
||||
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
|
||||
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') } } } } : {}),
|
||||
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') } } },
|
||||
...(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)') } : {}),
|
||||
...(def.params ? { 404: errorResponse('Not found (NOT_FOUND)') } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Cross Events API',
|
||||
version: opts.version,
|
||||
description: [
|
||||
'Version 2 of the Cross Events API. Every response is JSON:',
|
||||
'- a single item is `{ "data": { … } }`;',
|
||||
'- a list is `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, paged with `?page=` and `?limit=` (at most 100);',
|
||||
'- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.',
|
||||
'',
|
||||
'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer <token>`.',
|
||||
'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: opts.serverUrl ?? '/api/v2' }],
|
||||
tags: Array.from(new Set(list.map(r => r.tag))).map(name => ({ name })),
|
||||
paths,
|
||||
components: {
|
||||
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
|
||||
schemas: { Error: errorSchema },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** `?page=2&limit=25` on every v2 list (limit 1–100, default 25). */
|
||||
export const pageQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1).describe('Page number, from 1'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Items per page (at most 100)'),
|
||||
});
|
||||
export type PageQuery = z.infer<typeof pageQuery>;
|
||||
|
||||
export const pageMeta = z.object({
|
||||
page: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
total: z.number().int().describe('Items across all pages'),
|
||||
pages: z.number().int().describe('Number of pages (at least 1)'),
|
||||
});
|
||||
|
||||
/** The response shape of a v2 list: `{ data: [...], meta: { page, limit, total, pages } }`. */
|
||||
export const paged = <T extends z.ZodType>(item: T) => z.object({ data: z.array(item), meta: pageMeta });
|
||||
|
||||
/** The response shape of a single v2 resource: `{ data: {...} }`. */
|
||||
export const single = <T extends z.ZodType>(item: T) => z.object({ data: item });
|
||||
|
||||
/** Prisma's skip/take for a page. */
|
||||
export const skipTake = ({ page, limit }: PageQuery) => ({ skip: (page - 1) * limit, take: limit });
|
||||
|
||||
export function pageOf<T>(data: T[], total: number, { page, limit }: PageQuery) {
|
||||
return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
|
||||
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
|
||||
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
|
||||
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
|
||||
* access checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
|
||||
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
|
||||
const audit = require('../../controllers/adminAuditController');
|
||||
const imp = require('../../controllers/impersonationController');
|
||||
const jobs = require('../../controllers/jobsController');
|
||||
const settings = require('../../controllers/settingsController');
|
||||
const hub = require('../../controllers/hubController');
|
||||
const hooks = require('../../controllers/outboundWebhookController');
|
||||
const backups = require('../../controllers/backupController');
|
||||
|
||||
const loose = z.looseObject({});
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(500) });
|
||||
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// ─── My privacy ──────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
|
||||
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
|
||||
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
|
||||
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
|
||||
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
|
||||
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
|
||||
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
|
||||
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
|
||||
|
||||
// ─── Privacy admin ───────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
|
||||
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
|
||||
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
|
||||
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
|
||||
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
|
||||
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
|
||||
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
|
||||
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
|
||||
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
|
||||
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
|
||||
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
|
||||
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
|
||||
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
|
||||
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
|
||||
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
|
||||
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
|
||||
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
|
||||
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
|
||||
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
|
||||
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
|
||||
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
|
||||
|
||||
// ─── Admin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
|
||||
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
|
||||
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
|
||||
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
|
||||
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
|
||||
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
|
||||
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
|
||||
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
|
||||
|
||||
// ─── Settings and first-run set-up ───────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
|
||||
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
|
||||
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
|
||||
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
|
||||
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
|
||||
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
|
||||
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
|
||||
|
||||
// ─── Hub ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
|
||||
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
|
||||
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
|
||||
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
|
||||
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
|
||||
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
|
||||
v1Route({
|
||||
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
|
||||
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
|
||||
});
|
||||
|
||||
// ─── Outbound webhooks ───────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
|
||||
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
|
||||
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
|
||||
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
|
||||
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
|
||||
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
|
||||
|
||||
// ─── Backups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
|
||||
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
|
||||
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
|
||||
|
||||
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
|
||||
|
||||
const yoco = require('../../controllers/yocoController');
|
||||
const payfast = require('../../controllers/payfastController');
|
||||
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
|
||||
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
|
||||
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
|
||||
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
|
||||
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
|
||||
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
|
||||
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { aiStatus, getAiSettings, saveAiSettings, usageThisMonth, HELPERS, clearAiStatusCache } from '../../ai';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
|
||||
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)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Tickets and attendance on v2 (1.51.12): tickets (mine, one, sending, transfers, the staff
|
||||
* lists, generating), scan locks (offline scanning), the attendance register and certificates,
|
||||
* and allocations (groups, rooms, teams). Each route runs the v1 handler (viaV1.ts) behind the
|
||||
* same access and campus checks as v1. Scanning itself is /checkin (1.51.2).
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean; nameCampus?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
campusIndependent(reason: string): any;
|
||||
};
|
||||
const t = require('../../controllers/ticketController');
|
||||
const locks = require('../../controllers/scanLockController');
|
||||
const att = require('../../controllers/attendanceController');
|
||||
const alloc = require('../../controllers/allocationController');
|
||||
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
const loose = z.looseObject({});
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const ticketOwner = cs.scopeEvent({ resolve: 'ticket', ownerOk: true });
|
||||
const qrScope = cs.scopeEvent({ resolve: 'ticketQr', from: (req: any) => req.params.qrCode, nameCampus: true });
|
||||
|
||||
// ─── Tickets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', v1: t.getUserTickets });
|
||||
v1Route({
|
||||
method: 'post', path: '/me/tickets/email', tag: 'Tickets', summary: 'Send me my tickets',
|
||||
description: 'For a registration (`registrationId`) or chosen tickets (`ticketIds`), by email and/or WhatsApp (`channel`).',
|
||||
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), v1: t.emailTickets,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), v1: t.getRecentScans,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), v1: t.getScanStats,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/tickets/send', tag: 'Tickets', summary: 'Send tickets to someone',
|
||||
description: 'Staff: a registration\'s tickets (or chosen ones) to an email address or phone number.',
|
||||
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, v1: t.sendTicketsTo,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/tickets/generate', tag: 'Tickets', summary: 'Issue a registration\'s tickets',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), v1: t.generateTickets,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, v1: t.markTicketsAsEmailSent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), v1: t.getTickets,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets/qr/:qrCode', tag: 'Tickets', summary: 'The ticket for a QR code',
|
||||
description: 'Without checking it in (that\'s POST /checkin/{qrCode}).',
|
||||
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), v1: t.getTicketByQrCode,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/tickets', tag: 'Tickets', summary: 'An event\'s tickets',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: t.getTicketsByEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/ticket-transfers/:token', tag: 'Tickets', summary: 'A ticket someone is giving you',
|
||||
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, v1: t.getTransferOffer,
|
||||
});
|
||||
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, v1: t.acceptTransferOffer });
|
||||
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, v1: t.declineTransferOffer });
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketById,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Give a ticket to someone',
|
||||
description: 'Sends them a link to accept it; the ticket is theirs once they do.',
|
||||
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, v1: t.transferTicketHandler,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.cancelTransferHandler,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketTransfers,
|
||||
});
|
||||
|
||||
// ─── Scan locks (offline scanning) ───────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/scan-locks', tag: 'Check-in', summary: 'Devices scanning offline for an event',
|
||||
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), v1: locks.listLocks,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/scan-locks', tag: 'Check-in', summary: 'Take tickets offline on this device',
|
||||
description: 'Locks the chosen tickets to this device until it syncs or releases them.',
|
||||
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: locks.createLock,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/scan-locks/reports', tag: 'Check-in', summary: 'Offline sync reports to review',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), v1: locks.listReports,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/scan-locks/reports/:id/review', tag: 'Check-in', summary: 'Mark a sync report reviewed',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, v1: locks.reviewReport,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/scan-locks/:id/snapshot', tag: 'Check-in', summary: 'The tickets a device took offline',
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), v1: locks.getSnapshot,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/scan-locks/:id/sync', tag: 'Check-in', summary: 'Upload scans made offline',
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.syncLock,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/scan-locks/:id/force-release', tag: 'Check-in', summary: 'Release a lost device\'s tickets',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.forceRelease,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/scan-locks/:id', tag: 'Check-in', summary: 'Release tickets back online',
|
||||
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), v1: locks.releaseLock,
|
||||
});
|
||||
|
||||
// ─── Attendance and certificates ─────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/certificates/verify/:code', tag: 'Attendance', summary: 'Check a certificate is genuine',
|
||||
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), v1: att.verifyCertificate,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', v1: att.myCertificates });
|
||||
v1Route({
|
||||
method: 'post', path: '/certificates/preview', tag: 'Attendance', summary: 'Preview a certificate template',
|
||||
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, v1: att.previewCertificate,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
|
||||
description: 'Its owner, or staff.', access: 'user', params: idParams, file: { contentType: 'application/pdf' }, v1: att.downloadCertificate,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, v1: att.revokeCertificate,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/attendance', tag: 'Attendance', summary: 'An event\'s attendance register',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: att.getRegister,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
|
||||
description: '`format=pdf` or `xlsx`.', access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('format', 'sessionId'),
|
||||
file: { contentType: 'application/pdf' }, map: asEvent, v1: att.exportRegister,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.markAttendance,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/certificates', tag: 'Attendance', summary: 'Issue certificates',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.issueCertificates,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/certificates/send', tag: 'Attendance', summary: 'Send certificates',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.sendCertificatesHandler,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, file: { contentType: 'application/pdf' }, map: asEvent, v1: att.downloadEventCertificates,
|
||||
});
|
||||
|
||||
// ─── Allocations (groups, rooms, teams) ──────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
|
||||
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], v1: alloc.getMyGroups,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/allocations', tag: 'Allocations', summary: 'An event\'s allocation board',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: alloc.getBoard,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/allocation-sets', tag: 'Allocations', summary: 'Start a set of groups',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: alloc.createSet,
|
||||
});
|
||||
const setScope = cs.scopeEvent({ resolve: 'allocationSet' });
|
||||
const groupScope = cs.scopeEvent({ resolve: 'allocationGroup' });
|
||||
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.updateSet });
|
||||
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.deleteSet });
|
||||
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), v1: alloc.getSetLists });
|
||||
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, v1: alloc.createGroup });
|
||||
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.assign });
|
||||
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.clearAssignments });
|
||||
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.autoAssign });
|
||||
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, v1: alloc.updateGroup });
|
||||
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, v1: alloc.deleteGroup });
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { id } from '../schemas';
|
||||
import auth from '../../middleware/authMiddleware';
|
||||
import { signUp, logIn, verifyLoginCode, type Session } from '../../services/auth';
|
||||
|
||||
const requestInfo = (req: Request) => {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return { ip: getClientIp(req), userAgent: req.headers['user-agent'] };
|
||||
};
|
||||
|
||||
const session = z.object({
|
||||
status: z.literal('signed_in'),
|
||||
token: z.string().describe('Send as `Authorization: Bearer <token>`'),
|
||||
user: z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) }),
|
||||
newsPromptPending: z.boolean().describe('Ask once whether they want News & announcements'),
|
||||
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]).describe('A privacy notice they must accept: never accepted (first) or a newer version (updated)'),
|
||||
deviceToken: z.string().optional().describe('With rememberDevice: send it with the next login to skip the code on this device'),
|
||||
});
|
||||
|
||||
const toSession = (s: Session): z.input<typeof session> => ({
|
||||
status: 'signed_in',
|
||||
token: s.token,
|
||||
user: { id: s.id, name: s.name, email: s.email, role: s.role as 'admin' | 'supervisor' | 'staff' | 'user' },
|
||||
newsPromptPending: s.newsPromptPending,
|
||||
noticePromptPending: s.noticePromptPending,
|
||||
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
|
||||
});
|
||||
|
||||
export const signup = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/signup',
|
||||
tag: 'Accounts',
|
||||
summary: 'Create an account',
|
||||
description: 'Signs the new account in straight away. `acceptTerms` must be true (the Terms of Use and Privacy Policy); `newsOptIn` is the unticked "News & announcements" box.',
|
||||
access: 'public',
|
||||
status: 201,
|
||||
body: z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.email().max(200),
|
||||
password: z.string().min(8).max(200),
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional().describe('WhatsApp needs a valid South African number; otherwise email is used'),
|
||||
newsOptIn: z.boolean().optional(),
|
||||
acceptTerms: z.literal(true),
|
||||
}),
|
||||
response: single(session),
|
||||
async handler({ body, req }) {
|
||||
const made = await signUp({ name: body.name, email: body.email, password: body.password, phoneNumber: body.phone, notificationPreference: body.notificationPreference, newsOptIn: body.newsOptIn }, requestInfo(req));
|
||||
return { data: { status: 'signed_in' as const, token: made.token, user: { id: made.id, name: made.name, email: made.email, role: made.role }, newsPromptPending: false, noticePromptPending: false as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const login = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/login',
|
||||
tag: 'Accounts',
|
||||
summary: 'Log in',
|
||||
description: [
|
||||
'With an email address or phone number. Five wrong passwords lock the account for five minutes (`RATE_LIMITED`).',
|
||||
'With two-factor authentication (always for supervisors and admins), the answer is `code_required`: a code was sent by email or WhatsApp — finish with `POST /auth/login/code`.',
|
||||
].join('\n\n'),
|
||||
access: 'public',
|
||||
middleware: [auth.loginLimiter],
|
||||
body: z.object({
|
||||
identifier: z.string().trim().min(1).max(200).describe('Email address or phone number'),
|
||||
password: z.string().min(1).max(200),
|
||||
deviceToken: z.string().max(500).optional().describe('From an earlier login with rememberDevice'),
|
||||
}),
|
||||
response: single(z.discriminatedUnion('status', [
|
||||
session,
|
||||
z.object({
|
||||
status: z.literal('code_required'),
|
||||
challengeId: z.string(),
|
||||
method: z.enum(['email', 'whatsapp']),
|
||||
destination: z.string().nullable().describe('Where the code went, partly hidden'),
|
||||
enrolling: z.boolean().describe('true = this turns two-factor on (required for the role)'),
|
||||
}),
|
||||
])),
|
||||
async handler({ body, req }) {
|
||||
const result = await logIn(body, requestInfo(req));
|
||||
if (result.status === 'signed_in') return { data: toSession(result.session) };
|
||||
return { data: { status: 'code_required' as const, challengeId: result.challenge.challengeId, method: result.challenge.method as 'email' | 'whatsapp', destination: result.challenge.destination ?? null, enrolling: result.enrolling } };
|
||||
},
|
||||
});
|
||||
|
||||
export const loginCode = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/login/code',
|
||||
tag: 'Accounts',
|
||||
summary: 'Finish logging in with the code',
|
||||
access: 'public',
|
||||
middleware: [auth.loginLimiter],
|
||||
body: z.object({
|
||||
challengeId: z.string().min(1).max(100),
|
||||
code: z.string().trim().min(4).max(12),
|
||||
rememberDevice: z.boolean().optional().describe('Skip the code on this device next time (30 days)'),
|
||||
}),
|
||||
response: single(session),
|
||||
async handler({ body, req }) {
|
||||
return { data: toSession(await verifyLoginCode(body, requestInfo(req))) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { id } from '../schemas';
|
||||
import { previewScan, scanTicket, type ScanTicket } from '../../services/checkin';
|
||||
|
||||
const OUTCOMES = ['checked_in', 'already_used', 'consent_required', 'wrong_event', 'wrong_section', 'section_not_found', 'no_session', 'locked', 'transferred'] as const;
|
||||
|
||||
const qrParams = z.object({ qrCode: z.string().min(1).max(200).describe('The code in the ticket\'s QR') });
|
||||
|
||||
/**
|
||||
* Campus-scoped staff may only check in tickets for their campuses' events (and church-wide
|
||||
* ones) — the same rule as v1's scopeEvent({ resolve: 'ticketQr', nameCampus: true }).
|
||||
*/
|
||||
async function assertTicketCampus(req: Request, qrCode: string) {
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<object | null>;
|
||||
eventOf(resolve: string, value: string): Promise<{ campusId: string | null; campusName?: string | null } | null>;
|
||||
canView(scope: object, campusId: string | null): boolean;
|
||||
};
|
||||
const scope = await cs.scopeFor(req);
|
||||
if (!scope) return;
|
||||
const ev = await cs.eventOf('ticketQr', qrCode);
|
||||
if (ev && !cs.canView(scope, ev.campusId)) throw new ApiError('FORBIDDEN', `This ticket is for an event at ${ev.campusName || 'another campus'}.`);
|
||||
}
|
||||
|
||||
const ticketInfo = z.object({
|
||||
id,
|
||||
eventId: id,
|
||||
eventTitle: z.string().nullable(),
|
||||
ticketName: z.string().nullable().describe('The ticket option, e.g. "Adult"'),
|
||||
quantity: z.number().int().describe('People on this ticket'),
|
||||
});
|
||||
const toTicketInfo = (t: ScanTicket) => ({ id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null, quantity: t.quantity });
|
||||
|
||||
const sessionRef = z.object({ id, label: z.string() });
|
||||
|
||||
export const preview = defineRoute({
|
||||
method: 'get',
|
||||
path: '/checkin/:qrCode',
|
||||
tag: 'Check-in',
|
||||
summary: 'Look a ticket up before checking it in',
|
||||
description: 'Who it is for, how many are left to check in (for today\'s session on multi-day events, or `sessionId`), and parental consent. Nothing is changed.',
|
||||
access: 'staff',
|
||||
campusScope: 'event:ticketQr:view (assertTicketCampus)',
|
||||
params: qrParams,
|
||||
query: z.object({ sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)') }),
|
||||
response: single(z.object({
|
||||
outcome: z.enum(['found', 'transferred']),
|
||||
message: z.string().nullable().describe('For a transferred ticket: who has it now'),
|
||||
ticket: z.object({
|
||||
id,
|
||||
eventId: id,
|
||||
eventTitle: z.string().nullable(),
|
||||
ticketOptionId: id.nullable().describe('For section checks'),
|
||||
ticketName: z.string().nullable(),
|
||||
variant: z.string().nullable(),
|
||||
attendee: z.string().nullable().describe('The attendee named on it, if any'),
|
||||
bookedBy: z.string().nullable().describe('Who registered'),
|
||||
quantity: z.number().int(),
|
||||
remaining: z.number().int().describe('Still to check in (this session, on multi-day events)'),
|
||||
session: sessionRef.nullable(),
|
||||
sessionError: z.string().nullable().describe('Why no session is open for check-in now'),
|
||||
consentStatus: z.string().describe('not_required, given, pending, declined or missing'),
|
||||
}).nullable(),
|
||||
})),
|
||||
async handler({ params, query, req }) {
|
||||
await assertTicketCampus(req, params.qrCode);
|
||||
const result = await previewScan(params.qrCode, { sessionId: query.sessionId ?? null });
|
||||
if (result.outcome === 'transferred') return { data: { outcome: 'transferred' as const, message: result.message, ticket: null } };
|
||||
const t = result.ticket;
|
||||
return {
|
||||
data: {
|
||||
outcome: 'found' as const,
|
||||
message: null,
|
||||
ticket: {
|
||||
id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null,
|
||||
ticketOptionId: t.registrationOption.eventOption.id ?? null, variant: t.registrationOption.variant?.name ?? null,
|
||||
attendee: t.attendee?.name ?? null, bookedBy: t.user.name ?? null, quantity: t.quantity,
|
||||
remaining: t.remaining, session: t.session, sessionError: t.sessionError, consentStatus: t.consentStatus,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const scan = defineRoute({
|
||||
method: 'post',
|
||||
path: '/checkin/:qrCode',
|
||||
tag: 'Check-in',
|
||||
summary: 'Check a ticket in',
|
||||
description: [
|
||||
'Checks in everyone left on the ticket, or `people`. Every normal result is a 200 with an `outcome`:',
|
||||
'`checked_in`, `already_used`, `consent_required` (a supervisor can resend with `overrideConsent`, which is logged), `wrong_event`, `wrong_section`, `section_not_found`, `no_session` (multi-day: nothing open now), `locked` (offline-locked to another device) or `transferred` (handed on; `message` says to whom).',
|
||||
'An unknown code is `NOT_FOUND`.',
|
||||
].join('\n\n'),
|
||||
access: 'staff',
|
||||
campusScope: 'event:ticketQr:view (assertTicketCampus)',
|
||||
params: qrParams,
|
||||
body: z.object({
|
||||
eventId: id.optional().describe('Refuse tickets for any other event'),
|
||||
sectionId: id.optional().describe('Only tickets valid for this section'),
|
||||
sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)'),
|
||||
people: z.number().int().min(1).max(10000).optional().describe('How many to check in (default: everyone left)'),
|
||||
overrideConsent: z.boolean().optional(),
|
||||
deviceId: z.string().max(200).optional().describe('This scanner, so its own offline lock doesn\'t block it'),
|
||||
}),
|
||||
response: single(z.object({
|
||||
outcome: z.enum(OUTCOMES),
|
||||
message: z.string(),
|
||||
ticket: ticketInfo.nullable(),
|
||||
checkedIn: z.number().int().describe('People checked in by this request (0 unless checked_in)'),
|
||||
remaining: z.number().int().nullable().describe('Still to check in afterwards'),
|
||||
session: sessionRef.nullable(),
|
||||
consentStatus: z.string().nullable(),
|
||||
canOverride: z.boolean().describe('consent_required: whether you may override'),
|
||||
})),
|
||||
async handler({ params, body, user, req }) {
|
||||
await assertTicketCampus(req, params.qrCode);
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
const r = await scanTicket(params.qrCode, user!, {
|
||||
eventId: body.eventId, sectionId: body.sectionId, sessionId: body.sessionId, deviceId: body.deviceId,
|
||||
qty: body.people ?? null, overrideConsent: body.overrideConsent,
|
||||
}, getClientIp(req));
|
||||
const base = { outcome: r.outcome, message: r.message, ticket: 'ticket' in r ? toTicketInfo(r.ticket) : null, checkedIn: 0, remaining: null as number | null, session: null as { id: string; label: string } | null, consentStatus: null as string | null, canOverride: false };
|
||||
if (r.outcome === 'checked_in') return { data: { ...base, checkedIn: r.qtyRedeemed, remaining: r.remaining, session: r.session } };
|
||||
if (r.outcome === 'already_used') return { data: { ...base, remaining: 0 } };
|
||||
if (r.outcome === 'consent_required') return { data: { ...base, consentStatus: r.consentStatus, canOverride: r.canOverride } };
|
||||
return { data: base };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, fileResponse, type ApiUser } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import * as details from '../../services/eventDetails';
|
||||
import { managedEvent } from './eventsManage';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<object | null>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
// The same multer step as v1 (field "file"; PDFs, Office files, text, CSV, ZIP; 20 MB).
|
||||
const { receiveAttachment } = require('../../controllers/eventController') as { receiveAttachment: any };
|
||||
|
||||
const urlOf = (req: Request) => (u: string | null | undefined) => absoluteUrl(req, u);
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
const viewerOf = async (req: Request, user: ApiUser | null) => ({ staff: !!user && ['admin', 'supervisor', 'staff'].includes(user.role), scope: await campusScope.scopeFor(req) });
|
||||
|
||||
// ─── The event in full, calendar file, short link ──────────────────────────────
|
||||
|
||||
export const full = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/full',
|
||||
tag: 'Events',
|
||||
summary: 'One event in full',
|
||||
description: [
|
||||
'What the registration page and the set-up screens need: ticket options with what\'s left (per variant, and per session for "pick sessions" tickets), early-bird tiers, sessions with bookings, the attendee form, attachments, bundle deals on sale and the next event in its series.',
|
||||
'Staff (of a campus they work with) also get inactive and not-yet-live events, who created it, who gets its emails, and the kids/volunteer tickets. Everyone else gets NOT_FOUND for events that aren\'t public.',
|
||||
].join('\n\n'),
|
||||
access: 'optional',
|
||||
params: idParams,
|
||||
response: single(managedEvent.extend({
|
||||
form: z.looseObject({ id, isRequired: z.boolean(), fields: z.array(z.looseObject({ id, type: z.string(), label: z.string() })) }).nullable(),
|
||||
attachments: z.array(z.looseObject({ id, originalName: z.string(), url: z.string().nullable() })),
|
||||
})),
|
||||
async handler({ params, user, req }) {
|
||||
return { data: plain(await details.getEventDetail(params.id, await viewerOf(req, user), urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const calendar = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/calendar.ics',
|
||||
tag: 'Events',
|
||||
summary: 'The event as a calendar file',
|
||||
description: 'An .ics file (each session for a multi-day event). Same visibility as the event.',
|
||||
access: 'optional',
|
||||
file: { contentType: 'text/calendar' },
|
||||
params: idParams,
|
||||
response: fileResponse,
|
||||
async handler({ params, user, req }) {
|
||||
const { filename, ics } = await details.eventCalendar(params.id, await viewerOf(req, user));
|
||||
return { data: { contentType: 'text/calendar; charset=utf-8', filename, body: ics } };
|
||||
},
|
||||
});
|
||||
|
||||
export const byAlias = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/by-alias/:alias',
|
||||
tag: 'Events',
|
||||
summary: 'An event by its short link',
|
||||
description: 'The part after the site address (`/camp2026`), any letter case. Active events only.',
|
||||
access: 'public',
|
||||
params: z.object({ alias: z.string().min(1).max(100).describe('The short link, e.g. camp2026') }),
|
||||
response: single(z.object({
|
||||
id, title: z.string(), description: z.string().nullable(), startDate: isoDate, endDate: isoDate, registrationDeadline: isoDate.nullable(),
|
||||
goLiveAt: isoDate.nullable(), price: z.number(), picture: z.string().nullable(), redirectUrl: z.string().nullable(),
|
||||
isLive: z.boolean().describe('Not over yet'),
|
||||
})),
|
||||
async handler({ params, req }) {
|
||||
const ev = plain(await details.eventByAlias(params.alias));
|
||||
return { data: { ...ev, picture: absoluteUrl(req, ev.picture) } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Ticket options and variants ─────────────────────────────────────────────
|
||||
|
||||
const variant = z.looseObject({ id, name: z.string(), price: z.number().nullable(), stockLimit: z.number().int(), staffPrice: z.number().nullable(), order: z.number().int() });
|
||||
const tier = z.looseObject({ id, deadline: isoDate, price: z.number(), stockLimit: z.number().int(), variantId: id.nullable() });
|
||||
const ticketOption = z.looseObject({
|
||||
id, eventId: id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int(), staffPrice: z.number().nullable(),
|
||||
sessionMode: z.enum(['all', 'pick']), earlyBirdTiers: z.array(tier).optional(), variants: z.array(variant).optional(),
|
||||
});
|
||||
const money = z.union([z.number(), z.string().max(20)]);
|
||||
const optionBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
price: money,
|
||||
isMainTicket: z.boolean().optional(),
|
||||
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional().describe('0 = no limit'),
|
||||
staffPrice: money.nullable().optional().describe('What staff pay (empty = the normal price)'),
|
||||
sessionMode: z.enum(['all', 'pick']).optional().describe('"pick" = buyers choose sessions (multi-day events)'),
|
||||
});
|
||||
const variantBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
price: money.nullable().optional().describe('Empty = the option\'s price'),
|
||||
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional(),
|
||||
staffPrice: money.nullable().optional(),
|
||||
order: z.union([z.number().int(), z.string().max(10)]).optional(),
|
||||
});
|
||||
|
||||
export const createOption = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/ticket-options',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a ticket option',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: optionBody,
|
||||
response: single(ticketOption),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.createOption(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateOption = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/ticket-options/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a ticket option',
|
||||
description: 'Only what\'s sent changes. `earlyBirdTiers` and `variants`, when sent, are the full lists: kept by id, added without one, and ones left out removed when nothing was bought under them.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(eventOption, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: optionBody.partial().extend({
|
||||
earlyBirdTiers: z.array(z.looseObject({ id: id.optional(), deadline: z.string().max(40), price: money, stockLimit: z.union([z.number(), z.string()]).optional(), variantId: id.nullable().optional() })).max(50).optional(),
|
||||
variants: z.array(variantBody.extend({ id: id.optional() })).max(100).optional(),
|
||||
}),
|
||||
response: single(ticketOption),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.updateOption(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteOption = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/ticket-options/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a ticket option',
|
||||
description: 'Admins only. Not one anybody has bought, and never the event\'s only main ticket.',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteOption(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const createVariant = defineRoute({
|
||||
method: 'post',
|
||||
path: '/ticket-options/:id/variants',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a variant (e.g. a T-shirt size)',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(eventOption, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: variantBody,
|
||||
response: single(variant),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.createVariant(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateVariant = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/variants/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a variant',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(optionVariant, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'optionVariant', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: variantBody.partial(),
|
||||
response: single(variant),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.updateVariant(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteVariant = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/variants/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a variant',
|
||||
description: 'Admins only, and not one anybody has bought.',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteVariant(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
const attachment = z.object({ id, eventId: id, originalName: z.string(), filename: z.string(), mimeType: z.string(), size: z.number().int(), url: z.string().nullable(), createdAt: isoDate });
|
||||
|
||||
export const listAttachments = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/attachments',
|
||||
tag: 'Events',
|
||||
summary: 'An event\'s attachments',
|
||||
description: 'Files for attendees (programmes, packing lists…), newest first.',
|
||||
access: 'public',
|
||||
params: idParams,
|
||||
response: z.object({ data: z.array(attachment) }),
|
||||
async handler({ params, req }) {
|
||||
return { data: plain(await details.listAttachments(params.id, urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadAttachment = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/attachments',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Attach a file to an event',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' }), receiveAttachment],
|
||||
upload: { field: 'file', description: 'PDF, Word, Excel, PowerPoint, text, CSV or ZIP, up to 20 MB' },
|
||||
status: 201,
|
||||
params: idParams,
|
||||
response: single(attachment),
|
||||
async handler({ params, req }) {
|
||||
const r = req as Request & { multerError?: Error; file?: { originalname: string; filename: string; mimetype: string; size: number } };
|
||||
if (r.multerError) throw new ApiError('VALIDATION_FAILED', r.multerError.message, { file: r.multerError.message });
|
||||
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { file: 'Choose a file' });
|
||||
return { data: plain(await details.recordAttachment(params.id, r.file, urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachment = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/events/:id/attachments/:attachmentId',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove an attachment',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: z.object({ id, attachmentId: id }),
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteAttachment(params.id, params.attachmentId);
|
||||
return { data: { id: params.attachmentId, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const attachmentsStatus = defineRoute({
|
||||
method: 'get',
|
||||
path: '/attachments/status',
|
||||
tag: 'Admin',
|
||||
summary: 'Attachments diagnostics',
|
||||
access: 'admin',
|
||||
response: single(z.looseObject({ databaseTableQueryable: z.boolean(), probeError: z.string().nullable() })),
|
||||
async handler() {
|
||||
return { data: plain(await details.attachmentsStatus()) };
|
||||
},
|
||||
});
|
||||
|
||||
export const attachmentsSync = defineRoute({
|
||||
method: 'post',
|
||||
path: '/attachments/sync',
|
||||
tag: 'Admin',
|
||||
summary: 'Import attachments from old manifests',
|
||||
description: 'Attachments recorded in JSON files on disk by very old versions go into the database. `dryRun` only reports; `remove` deletes each manifest once imported.',
|
||||
access: 'admin',
|
||||
body: z.object({ dryRun: z.boolean().default(false), remove: z.boolean().default(false) }),
|
||||
response: single(z.looseObject({ dryRun: z.boolean(), remove: z.boolean() })),
|
||||
async handler({ body }) {
|
||||
return { data: plain(await details.syncAttachmentManifests(body)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { notFound } from '../errors';
|
||||
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
|
||||
import { absoluteUrl, campusRef, id, idParams, iso, isoDate, seriesRef } from '../schemas';
|
||||
import { prisma, campusesEnabled, campusWhere, resolveCampusFilter, publicEventWhere, publicOptions, getEffectiveUnitPrice } from '../legacy';
|
||||
|
||||
const eventSummary = z.object({
|
||||
id,
|
||||
title: z.string(),
|
||||
startDate: isoDate,
|
||||
endDate: isoDate,
|
||||
location: z.string().nullable(),
|
||||
price: z.number().describe('The headline price in rand (0 = free)'),
|
||||
pictureUrl: z.string().nullable(),
|
||||
requiresRegistration: z.boolean(),
|
||||
scheduleType: z.enum(['single', 'sessions']),
|
||||
campus: campusRef.nullable().describe('null = for all campuses (or campuses are off)'),
|
||||
series: seriesRef.nullable(),
|
||||
});
|
||||
|
||||
const ticketOption = z.object({
|
||||
id,
|
||||
name: z.string(),
|
||||
price: z.number().describe('The normal price in rand'),
|
||||
currentPrice: z.number().describe('What it costs now (an early-bird price while one runs)'),
|
||||
isMainTicket: z.boolean(),
|
||||
sessionMode: z.enum(['all', 'pick']).describe('"pick" = the buyer chooses sessions'),
|
||||
});
|
||||
|
||||
const session = z.object({
|
||||
id,
|
||||
label: z.string().nullable(),
|
||||
startsAt: isoDate,
|
||||
endsAt: isoDate,
|
||||
location: z.string().nullable(),
|
||||
cancelled: z.boolean(),
|
||||
});
|
||||
|
||||
const eventDetail = eventSummary.extend({
|
||||
description: z.string().nullable(),
|
||||
registrationDeadline: isoDate.nullable(),
|
||||
ticketOptions: z.array(ticketOption),
|
||||
sessions: z.array(session).describe('Multi-day events only; empty otherwise'),
|
||||
});
|
||||
|
||||
const SUMMARY_SELECT = {
|
||||
id: true, title: true, startDate: true, endDate: true, location: true, price: true, picture: true,
|
||||
requiresRegistration: true, scheduleType: true,
|
||||
campus: { select: { id: true, name: true, slug: true } },
|
||||
series: { select: { id: true, name: true, slug: true } },
|
||||
} as const;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; title: string; startDate: Date; endDate: Date; location: string | null; price: number; picture: string | null;
|
||||
requiresRegistration: boolean; scheduleType: 'single' | 'sessions';
|
||||
campus: { id: string; name: string; slug: string } | null; series: { id: string; name: string; slug: string } | null;
|
||||
};
|
||||
|
||||
const toSummary = (req: Parameters<typeof absoluteUrl>[0], e: SummaryRow): z.input<typeof eventSummary> => ({
|
||||
id: e.id, title: e.title, startDate: iso(e.startDate), endDate: iso(e.endDate), location: e.location ?? null,
|
||||
price: e.price, pictureUrl: absoluteUrl(req, e.picture), requiresRegistration: e.requiresRegistration,
|
||||
scheduleType: e.scheduleType, campus: e.campus ?? null, series: e.series ?? null,
|
||||
});
|
||||
|
||||
export const listEvents = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events',
|
||||
tag: 'Events',
|
||||
summary: 'Upcoming public events',
|
||||
description: 'Events anyone can see (active, not hidden, live) that have not ended, soonest first.',
|
||||
access: 'public',
|
||||
query: pageQuery.extend({
|
||||
q: z.string().trim().max(100).optional().describe('Search the title'),
|
||||
campus: z.string().max(64).optional().describe("A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only"),
|
||||
series: z.string().max(120).optional().describe('A series slug'),
|
||||
}),
|
||||
response: paged(eventSummary),
|
||||
async handler({ query, req }) {
|
||||
const now = new Date();
|
||||
const enabled = await campusesEnabled();
|
||||
const and: object[] = [publicEventWhere(now), { endDate: { gte: now } }];
|
||||
if (query.campus) and.push(campusWhere(await resolveCampusFilter(query.campus), { enabled, withAllCampuses: true }));
|
||||
if (query.series) and.push({ series: { slug: query.series } });
|
||||
if (query.q) and.push({ title: { contains: query.q, mode: 'insensitive' } });
|
||||
const where = { AND: and };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.event.findMany({ where, select: SUMMARY_SELECT, orderBy: [{ startDate: 'asc' }, { id: 'asc' }], ...skipTake(query) }),
|
||||
prisma.event.count({ where }),
|
||||
]);
|
||||
return pageOf((rows as SummaryRow[]).map(e => toSummary(req, e)), total, query);
|
||||
},
|
||||
});
|
||||
|
||||
export const getEvent = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id',
|
||||
tag: 'Events',
|
||||
summary: 'One public event',
|
||||
description: 'With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).',
|
||||
access: 'public',
|
||||
params: idParams,
|
||||
response: single(eventDetail),
|
||||
async handler({ params, req }) {
|
||||
const now = new Date();
|
||||
const { isHidden: _hiddenOk, ...visible } = publicEventWhere(now);
|
||||
const e = await prisma.event.findFirst({
|
||||
where: { id: params.id, ...visible },
|
||||
select: {
|
||||
...SUMMARY_SELECT, description: true, registrationDeadline: true, kidsOptionId: true, volunteerOptionId: true,
|
||||
eventOptions: { include: { earlyBirdTiers: true }, orderBy: [{ isMainTicket: 'desc' }, { name: 'asc' }] },
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!e) throw notFound('That event');
|
||||
const options = publicOptions(e);
|
||||
return {
|
||||
data: {
|
||||
...toSummary(req, e as unknown as SummaryRow),
|
||||
description: e.description ?? null,
|
||||
registrationDeadline: iso(e.registrationDeadline),
|
||||
ticketOptions: options.map(o => ({
|
||||
id: o.id, name: o.name, price: o.price, currentPrice: getEffectiveUnitPrice(o, null, now),
|
||||
isMainTicket: o.isMainTicket, sessionMode: o.sessionMode,
|
||||
})),
|
||||
sessions: e.sessions.map(s => ({ id: s.id, label: s.label, startsAt: iso(s.startsAt), endsAt: iso(s.endsAt), location: s.location, cancelled: !!s.cancelledAt })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import {
|
||||
createEvent, updateEvent, deactivateEvent, listManagedEvents, getNotifyRecipients, setNotifyRecipients, copySetupFrom,
|
||||
} from '../../services/events';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- event records are passed through as stored */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<object | null>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
scopeBody(targets: [string, string][], opts?: object): any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Staff and supervisor views of events (1.51.7). Event records are large (ticket options with
|
||||
* tiers and variants, the attendee form, sessions…) and grow with features, so the schema types
|
||||
* the core fields and lets the rest through as stored (`looseObject`); dates come back as ISO
|
||||
* strings. Registered before resources/events.ts so /events/manage isn't read as an event id.
|
||||
*/
|
||||
const option = z.looseObject({ id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int() });
|
||||
const session = z.looseObject({ id, label: z.string().nullable(), startsAt: isoDate, endsAt: isoDate, cancelled: z.boolean() });
|
||||
export const managedEvent = z.looseObject({
|
||||
id,
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
startDate: isoDate,
|
||||
endDate: isoDate,
|
||||
isActive: z.boolean(),
|
||||
isHidden: z.boolean(),
|
||||
requiresRegistration: z.boolean(),
|
||||
scheduleType: z.enum(['single', 'sessions']),
|
||||
attendanceMode: z.enum(['whole', 'pick']),
|
||||
cashupStatus: z.enum(['open', 'closed']),
|
||||
campusId: id.nullable(),
|
||||
seriesId: id.nullable(),
|
||||
picture: z.string().nullable(),
|
||||
eventOptions: z.array(option).optional(),
|
||||
sessions: z.array(session).optional(),
|
||||
});
|
||||
|
||||
/** A stored record as JSON (dates → ISO strings), with the picture as an absolute URL. */
|
||||
const toJson = (req: Request, ev: any) => {
|
||||
const plain = JSON.parse(JSON.stringify(ev));
|
||||
if ('picture' in plain) plain.picture = absoluteUrl(req, plain.picture);
|
||||
return plain;
|
||||
};
|
||||
|
||||
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 });
|
||||
|
||||
// The event wizard's fields. Only these are named here; the rest of the wizard's fields (consent,
|
||||
// waitlist, sponsorship, sessions, late joining, certificates, form…) pass through as sent.
|
||||
const eventBody = z.looseObject({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
description: z.string().max(20000).nullable().optional(),
|
||||
startDate: z.string().max(40).optional(),
|
||||
endDate: z.string().max(40).optional(),
|
||||
price: z.union([z.number(), z.string().max(20)]).optional(),
|
||||
campusId: id.nullable().optional(),
|
||||
seriesId: id.nullable().optional(),
|
||||
scheduleType: z.enum(['single', 'sessions']).optional(),
|
||||
sessions: z.array(z.looseObject({})).max(200).optional(),
|
||||
});
|
||||
|
||||
export const listManaged = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/manage',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Every event staff can see',
|
||||
description: 'Hidden ones too. Upcoming by default; `past`, `inactive` and `closed` (cashed-up) widen it. Campus-scoped staff see their campuses\' events and church-wide ones. Not paged — it\'s what the event pickers search.',
|
||||
access: 'staff',
|
||||
campusScope: 'scopeList (in listManagedEvents)',
|
||||
query: z.object({
|
||||
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include events that have ended'),
|
||||
inactive: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include deactivated events'),
|
||||
closed: z.enum(['true', 'false']).default('true').transform(v => v === 'true').describe('Include cashed-up events (default true)'),
|
||||
campus: z.string().max(64).optional().describe("A campus id or slug, or 'all' for church-wide events only"),
|
||||
}),
|
||||
response: z.object({ data: z.array(managedEvent) }),
|
||||
async handler({ query, req }) {
|
||||
const events = await listManagedEvents({ includePast: query.past, includeInactive: query.inactive, excludeClosed: !query.closed, campus: query.campus }, await ctxOf(req));
|
||||
return { data: events.map(ev => toJson(req, ev)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const create = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Create an event',
|
||||
description: 'Gets a "Main Ticket" at the event price (unless registration is off). A campus-scoped supervisor must choose one of their campuses.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'campus checked in createEvent (assertCampusAllowed)',
|
||||
status: 201,
|
||||
body: eventBody.extend({ title: z.string().trim().min(1).max(200), startDate: z.string().max(40), endDate: z.string().max(40) }),
|
||||
response: single(managedEvent),
|
||||
async handler({ body, user, req }) {
|
||||
return { data: toJson(req, await createEvent(body, actorOf(user), await ctxOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const update = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/events/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change an event',
|
||||
description: 'Only the fields sent change. Sessions save with the event (a refused schedule change saves nothing). A cashed-up event can\'t change until an admin reopens it. The answer includes `sessionChanges` (added, moved, cancelled… counts) when sessions were sent.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: idParams,
|
||||
body: eventBody,
|
||||
response: single(managedEvent.extend({ sessionChanges: z.record(z.string(), z.number()).nullable() })),
|
||||
async handler({ params, body, user, req }) {
|
||||
return { data: toJson(req, await updateEvent(params.id, body, actorOf(user), await ctxOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/events/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Deactivate an event',
|
||||
description: 'Admins only. The event is deactivated, not deleted: registrations, payments and tickets stay.',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, isActive: z.literal(false) })),
|
||||
async handler({ params, user, req }) {
|
||||
const ev = await deactivateEvent(params.id, actorOf(user), await ctxOf(req));
|
||||
return { data: { id: ev.id, isActive: false as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const copyFrom = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/copy-from',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Copy set-up from another event',
|
||||
description: 'Allocation groups, and planned costs for admins — used right after copying an event.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage) + scopeBody(fromEventId)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
|
||||
params: idParams,
|
||||
body: z.object({ fromEventId: id }),
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ params, body, user }) {
|
||||
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) });
|
||||
|
||||
export const listRecipients = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/notify-recipients',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Who gets this event\'s emails',
|
||||
description: 'Registration, payment and daily-summary emails.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(view)',
|
||||
middleware: [campusScope.scopeEvent()],
|
||||
params: idParams,
|
||||
response: z.object({ data: z.array(recipient) }),
|
||||
async handler({ params }) {
|
||||
return { data: await getNotifyRecipients(params.id) };
|
||||
},
|
||||
});
|
||||
|
||||
export const setRecipients = defineRoute({
|
||||
method: 'put',
|
||||
path: '/events/:id/notify-recipients',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Set who gets this event\'s emails',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: idParams,
|
||||
body: z.object({ userIds: z.array(id).max(200) }),
|
||||
response: z.object({ data: z.array(recipient) }),
|
||||
async handler({ params, body }) {
|
||||
return { data: await setNotifyRecipients(params.id, body.userIds) };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Kids check-in and volunteer rosters on v2 (1.51.13). Each route runs the v1 handler
|
||||
* (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
scopeList: any;
|
||||
};
|
||||
const kids = require('../../controllers/kidsController');
|
||||
const vol = require('../../controllers/volunteerController');
|
||||
|
||||
const loose = z.looseObject({});
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
/** v2 `:id` is the event; v1 called it `:eventId` (other params keep their names). */
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const view = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
|
||||
const manage = { campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })] };
|
||||
|
||||
// ─── Kids: my family ─────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/family', tag: 'Kids', summary: 'My family', description: 'Children, the adults who may collect them, and the family QR code.', access: 'user', v1: kids.getMine });
|
||||
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, v1: kids.createMine });
|
||||
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, v1: kids.renameMine });
|
||||
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', v1: kids.leaveMine });
|
||||
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, v1: kids.deleteMyChild });
|
||||
v1Route({ method: 'post', path: '/me/family/adults', tag: 'Kids', summary: 'Add an adult who may collect', access: 'user', status: 201, body: loose, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, v1: kids.deleteMyAdult });
|
||||
|
||||
// ─── Kids: set-up and the check-in desk ──────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/kids/setup-events', tag: 'Kids', summary: 'Events that can have kids check-in', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.setupEvents });
|
||||
v1Route({ method: 'get', path: '/kids/events', tag: 'Kids', summary: 'Events with kids check-in', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.listEvents });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'An event\'s kids check-in set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: kids.getSettings });
|
||||
v1Route({ method: 'put', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'Set up kids check-in', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: kids.saveSettings });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/board', tag: 'Kids', summary: 'Who is checked in, by room', access: 'staff', ...view, params: idParams, query: filters('sessionId', 'log'), map: asEvent, v1: kids.getBoard });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Find a family', access: 'staff', ...view, params: idParams, query: filters('q', 'sessionId'), map: asEvent, v1: kids.searchFamilies });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Register a family at the desk', access: 'staff', ...view, status: 201, params: idParams, body: loose, map: asEvent, v1: kids.createFamilyAtDesk });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families/qr/:token', tag: 'Kids', summary: 'The family for a QR code', access: 'staff', ...view, params: z.object({ id, token: z.string().min(1).max(200) }), map: asEvent, v1: kids.familyByQr });
|
||||
const familyParams = z.object({ id, familyId: id });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, v1: kids.getFamily });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/parents', tag: 'Kids', summary: 'Add a parent at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addParentAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/children', tag: 'Kids', summary: 'Add a child at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.saveChildAtDesk });
|
||||
v1Route({ method: 'put', path: '/events/:id/kids/families/:familyId/children/:childId', tag: 'Kids', summary: 'Change a child at the desk', access: 'staff', ...view, params: z.object({ id, familyId: id, childId: id }), body: loose, map: asEvent, v1: kids.saveChildAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/adults', tag: 'Kids', summary: 'Add an adult at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addAdultAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/check-in', tag: 'Kids', summary: 'Check children in', description: 'Answers the collection code and labels to print.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkIn });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/check-out', tag: 'Kids', summary: 'Check children out', description: 'With the collection code; refused for someone not allowed to collect.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkOut });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/code/:code', tag: 'Kids', summary: 'Who a collection code is for', access: 'staff', ...view, params: z.object({ id, code: z.string().min(1).max(40) }), query: filters('sessionId'), map: asEvent, v1: kids.lookupCode });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/duty', tag: 'Kids', summary: 'Say which room I\'m on', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.setDuty });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/offline', tag: 'Kids', summary: 'Everything the desk needs offline', access: 'staff', ...view, params: idParams, query: filters('deviceId', 'sessionId'), map: asEvent, v1: kids.offlineSnapshot });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/sync', tag: 'Kids', summary: 'Upload check-ins made offline', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.syncOffline });
|
||||
const checkInScope = { campusScope: 'scopeEvent(kidsCheckIn)', middleware: [cs.scopeEvent({ resolve: 'kidsCheckIn' })] };
|
||||
v1Route({ method: 'post', path: '/kids/check-ins/:id/override', tag: 'Kids', summary: 'Let someone not on the list collect', description: 'Logged, with the reason.', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.overrideCheckOut });
|
||||
v1Route({ method: 'post', path: '/kids/check-ins/:id/page', tag: 'Kids', summary: 'Page a child\'s parent', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.pageParent });
|
||||
v1Route({ method: 'put', path: '/kids/check-ins/:id/room', tag: 'Kids', summary: 'Move a child to another room', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.moveRoom });
|
||||
v1Route({ method: 'post', path: '/kids/alerts/:id/resolve', tag: 'Kids', summary: 'Resolve an alert', access: 'staff', campusScope: 'scopeEvent(kidsAlert)', middleware: [cs.scopeEvent({ resolve: 'kidsAlert' })], params: idParams, body: loose, v1: kids.resolveAlert });
|
||||
|
||||
// ─── Volunteering: mine ──────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', v1: vol.getMine });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Roles I can serve in at an event', access: 'user', params: idParams, map: asEvent, v1: vol.getEventRoles });
|
||||
v1Route({ method: 'post', path: '/volunteer-roles/:id/sign-up', tag: 'Volunteers', summary: 'Sign up to serve', access: 'user', status: 201, params: idParams, body: loose, v1: vol.signUp });
|
||||
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, v1: vol.withdraw });
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/swap', tag: 'Volunteers', summary: 'Ask someone to take my slot', access: 'user', params: idParams, body: loose, v1: vol.requestSwap });
|
||||
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, v1: vol.cancelSwap });
|
||||
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, v1: vol.acceptSwap });
|
||||
v1Route({ method: 'post', path: '/volunteering/check-in', tag: 'Volunteers', summary: 'Check myself in (the event\'s QR code)', access: 'user', body: loose, v1: vol.checkIn });
|
||||
|
||||
// ─── Volunteering: rosters ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), v1: vol.listEvents });
|
||||
v1Route({ method: 'get', path: '/volunteering/hours', tag: 'Volunteers', summary: 'Hours served', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'eventId'), v1: vol.hours });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers', tag: 'Volunteers', summary: 'An event\'s roster', access: 'staff', ...view, params: idParams, query: filters('log'), map: asEvent, v1: vol.getRoster });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers/qr', tag: 'Volunteers', summary: 'The self check-in QR code', access: 'staff', ...view, params: idParams, map: asEvent, v1: vol.getQr });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: vol.getSettings });
|
||||
v1Route({ method: 'put', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Set up volunteering', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: vol.saveSettings });
|
||||
v1Route({ method: 'post', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Add a role', access: 'supervisor', ...manage, status: 201, params: idParams, body: loose, map: asEvent, v1: vol.createRole });
|
||||
const roleManage = { campusScope: 'scopeEvent(servingRole, manage)', middleware: [cs.scopeEvent({ resolve: 'servingRole', need: 'manage' })] };
|
||||
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, v1: vol.updateRole });
|
||||
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, v1: vol.deleteRole });
|
||||
v1Route({ method: 'post', path: '/volunteer-roles/:id/assign', tag: 'Volunteers', summary: 'Put someone in a role', access: 'supervisor', campusScope: 'scopeEvent(servingRole)', middleware: [cs.scopeEvent({ resolve: 'servingRole' })], params: idParams, body: loose, v1: vol.assign });
|
||||
const slotScope = { campusScope: 'scopeEvent(volunteerSlot)', middleware: [cs.scopeEvent({ resolve: 'volunteerSlot' })] };
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, v1: vol.checkInManual });
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/remove', tag: 'Volunteers', summary: 'Take someone off a slot', access: 'supervisor', ...slotScope, params: idParams, body: loose, v1: vol.remove });
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
|
||||
import { id, iso, isoDate } from '../schemas';
|
||||
import { prisma } from '../legacy';
|
||||
import { registrationSummary, SUMMARY_INCLUDE, toSummary } from './registrations';
|
||||
import { updateProfile } from '../../services/auth';
|
||||
|
||||
const me = z.object({
|
||||
id,
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']),
|
||||
homeCampusId: id.nullable(),
|
||||
createdAt: isoDate,
|
||||
});
|
||||
|
||||
type MeRow = { id: string; name: string; email: string; phoneNumber: string | null; role: 'admin' | 'supervisor' | 'staff' | 'user'; twoFactorEnabled: boolean | null; notificationPreference: 'email' | 'whatsapp' | 'both'; homeCampusId: string | null; createdAt: Date };
|
||||
const toMe = (u: MeRow): z.input<typeof me> => ({
|
||||
id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled,
|
||||
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.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, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
|
||||
});
|
||||
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const updateMe = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/me',
|
||||
tag: 'Me',
|
||||
summary: 'Change my details',
|
||||
description: 'Only what is sent changes. A new `password` needs `currentPassword`; it sends a security notice and stops other devices skipping the 2FA code. The answer has a fresh `token` (the old one keeps working).',
|
||||
access: 'user',
|
||||
body: z.object({
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
email: z.email().max(200).optional(),
|
||||
phone: z.string().trim().max(40).nullable().optional().describe('null or empty removes it'),
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional(),
|
||||
homeCampusId: id.nullable().optional().describe('While campuses are on'),
|
||||
password: z.string().min(8).max(200).optional(),
|
||||
currentPassword: z.string().max(200).optional(),
|
||||
}),
|
||||
response: single(me).extend({ token: z.string() }),
|
||||
async handler({ body, user, req }) {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
const { user: u, token } = await updateProfile(user!.id, { ...body, phoneNumber: body.phone }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
|
||||
return { data: toMe(u), token };
|
||||
},
|
||||
});
|
||||
|
||||
export const listMyRegistrations = defineRoute({
|
||||
method: 'get',
|
||||
path: '/me/registrations',
|
||||
tag: 'Me',
|
||||
summary: 'My registrations',
|
||||
description: 'Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.',
|
||||
access: 'user',
|
||||
query: pageQuery.extend({
|
||||
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'),
|
||||
}),
|
||||
response: paged(registrationSummary),
|
||||
async handler({ query, user }) {
|
||||
const now = new Date();
|
||||
const where = query.past
|
||||
? { userId: user!.id }
|
||||
: { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.registration.findMany({ where, include: SUMMARY_INCLUDE, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], ...skipTake(query) }),
|
||||
prisma.registration.count({ where }),
|
||||
]);
|
||||
return pageOf(rows.map(r => toSummary(r, now)), total, query);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
|
||||
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
|
||||
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { listData, v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
unscopedOnly: any;
|
||||
};
|
||||
const pay = require('../../controllers/paymentController');
|
||||
const providers = require('../../controllers/paymentProvidersController');
|
||||
const cashup = require('../../controllers/cashupController');
|
||||
const ptx = require('../../controllers/providerTransactionsController');
|
||||
const waitlist = require('../../controllers/waitlistController');
|
||||
const sponsor = require('../../controllers/sponsorshipController');
|
||||
const consent = require('../../controllers/consentController');
|
||||
const forms = require('../../controllers/formController');
|
||||
const feedback = require('../../controllers/feedbackController');
|
||||
|
||||
/** Optional text filters, passed to v1 as they come. */
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
// Links in messages are credentials, so guessing stays slow (as on v1).
|
||||
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
|
||||
|
||||
// ─── Payments ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
|
||||
access: 'public', v1: providers.listPaymentProviders,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
|
||||
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
|
||||
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
|
||||
description: 'Records any that were paid (for when a webhook was missed).',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
|
||||
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
|
||||
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
|
||||
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
|
||||
v1: pay.createPayment,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
|
||||
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
|
||||
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
|
||||
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
|
||||
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
|
||||
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
|
||||
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
|
||||
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
|
||||
description: 'By email and/or WhatsApp, for what\'s still owing.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
|
||||
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
|
||||
access: 'public', params: idParams, v1: providers.getPayPage,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
|
||||
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
|
||||
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
|
||||
});
|
||||
|
||||
// ─── Cash-ups ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cashupBase = '/events/:id/cashup';
|
||||
v1Route({
|
||||
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
|
||||
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
|
||||
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
|
||||
});
|
||||
|
||||
// ─── Provider transactions ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
|
||||
});
|
||||
|
||||
// ─── Waitlist ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
|
||||
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
|
||||
v1Route({
|
||||
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
|
||||
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
|
||||
});
|
||||
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
|
||||
v1Route({
|
||||
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
|
||||
});
|
||||
}
|
||||
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
|
||||
|
||||
// ─── Sponsorship ─────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
|
||||
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
|
||||
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
|
||||
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
|
||||
});
|
||||
|
||||
// ─── Consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
|
||||
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
|
||||
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({}), v1: consent.resendRequest,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
|
||||
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
|
||||
});
|
||||
|
||||
// ─── Attendee form answers ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
|
||||
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
|
||||
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
|
||||
});
|
||||
|
||||
// ─── Feedback ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
|
||||
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
|
||||
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* People and messaging on v2 (1.51.14): the rest of accounts (password reset, activation,
|
||||
* two-step sign-in, sessions, closing an account), messaging attendees, email and WhatsApp
|
||||
* broadcasts, scheduled messages, automations, the site banner, saved filters, the dashboards,
|
||||
* stats, analytics and report files. Each route runs the v1 handler (viaV1.ts) behind the same
|
||||
* access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const { loginLimiter } = require('../../middleware/authMiddleware') as { loginLimiter: any };
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; from?: (req: any) => unknown }): any;
|
||||
scopeBroadcast(opts: { freeformField: string }): any;
|
||||
scopeList: any;
|
||||
unscopedOnly: any;
|
||||
campusIndependent(reason: string): any;
|
||||
};
|
||||
const users = require('../../controllers/userController');
|
||||
const events = require('../../controllers/eventController');
|
||||
const wa = require('../../controllers/whatsappController');
|
||||
const bc = require('../../controllers/broadcastController');
|
||||
const wbc = require('../../controllers/whatsappBroadcastController');
|
||||
const sched = require('../../controllers/scheduledEmailsController');
|
||||
const auto = require('../../controllers/automationController');
|
||||
const banner = require('../../controllers/bannerController');
|
||||
const stats = require('../../controllers/statsController');
|
||||
const analytics = require('../../controllers/analyticsController');
|
||||
const reports = require('../../controllers/reportController');
|
||||
// These two keep their handlers in the route file; v2 runs the v1 router with the v1 path.
|
||||
const savedFilterRouter = require('../../routes/savedFilterRoutes');
|
||||
const dashboardRouter = require('../../routes/dashboardRoutes');
|
||||
|
||||
const loose = z.looseObject({});
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
/** The query string of the v2 request, for a v1 router's URL. */
|
||||
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
|
||||
|
||||
// ─── Accounts ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
|
||||
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
|
||||
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
|
||||
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
|
||||
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
|
||||
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, v1: users.closeAccount });
|
||||
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', v1: users.getMyActivity });
|
||||
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, v1: users.verifyPassword });
|
||||
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, v1: users.startEnableTwoFactor });
|
||||
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, v1: users.confirmEnableTwoFactor });
|
||||
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, v1: users.disableTwoFactor });
|
||||
v1Route({
|
||||
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
|
||||
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
|
||||
query: filters('email', 'phone', 'phoneNumber'), v1: users.checkUserExists,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
|
||||
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
|
||||
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
|
||||
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), v1: users.getUsers,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, v1: users.getUserById });
|
||||
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, v1: users.updateUser });
|
||||
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, v1: users.deleteUser });
|
||||
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, v1: users.adminRevokeUserSessions });
|
||||
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, v1: users.anonymizeUser });
|
||||
|
||||
// ─── Messaging an event's people ─────────────────────────────────────────────
|
||||
|
||||
const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
|
||||
v1Route({
|
||||
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
|
||||
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
|
||||
access: 'public', query: filters('campus', 'series'), v1: events.getEvents,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', v1: events.getAllEvents });
|
||||
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.emailEventAttendees });
|
||||
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleEmailEventAttendees });
|
||||
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.whatsappEventAttendees });
|
||||
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleWhatsappEventAttendees });
|
||||
|
||||
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
|
||||
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
|
||||
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
|
||||
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
|
||||
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, v1: preview });
|
||||
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, v1: send });
|
||||
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, v1: schedule });
|
||||
}
|
||||
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), v1: sched.listScheduledEmails });
|
||||
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, v1: sched.updateScheduledEmail });
|
||||
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, v1: sched.deleteScheduledEmail });
|
||||
v1Route({
|
||||
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), v1: auto.scheduleAutomations,
|
||||
});
|
||||
|
||||
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', v1: wa.getConfigHandler });
|
||||
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, v1: wa.saveConfigHandler });
|
||||
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, v1: wa.createInstanceHandler });
|
||||
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', v1: wa.deleteInstanceHandler });
|
||||
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', v1: wa.getStatusHandler });
|
||||
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', v1: wa.getQrHandler });
|
||||
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
|
||||
v1Route({ method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose, v1: handler });
|
||||
}
|
||||
|
||||
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', v1: banner.getBanner });
|
||||
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, v1: banner.setBanner });
|
||||
|
||||
const OWN = 'campusIndependent (a person\'s own saved filters)';
|
||||
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
|
||||
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, v1: savedFilterRouter, map: () => ({ url: '/' }) });
|
||||
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
|
||||
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
|
||||
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
|
||||
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
|
||||
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getStaffDashboardStats });
|
||||
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getSupervisorDashboardStats });
|
||||
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', v1: stats.getAdminDashboardStats });
|
||||
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), v1: stats.getOverviewStats });
|
||||
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
|
||||
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, v1: analytics.trackStage });
|
||||
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: analytics.getFunnel });
|
||||
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, v1: reports.generatePdf });
|
||||
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, v1: reports.generateExcel });
|
||||
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, v1: reports.emailPdf });
|
||||
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, v1: reports.whatsappPdf });
|
||||
@@ -0,0 +1,187 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { ApiError, notFound } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { eventRef, id, idParams, iso, isoDate } from '../schemas';
|
||||
import { prisma, computeRegistrationTotalDue } from '../legacy';
|
||||
import { createRegistration, cancelRegistration } from '../../services/registrations';
|
||||
import { startRegistrationCheckout } from '../../services/payments';
|
||||
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration as v2 shows it to its owner. Also used by GET /me/registrations. */
|
||||
export const registrationSummary = z.object({
|
||||
id,
|
||||
status: z.enum(STATUSES),
|
||||
createdAt: isoDate,
|
||||
event: eventRef,
|
||||
items: z.array(z.object({
|
||||
ticket: z.string(),
|
||||
variant: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number(),
|
||||
})),
|
||||
totalDue: z.number().describe('In rand, after discounts'),
|
||||
amountPaid: z.number().describe('In rand (refunds subtracted)'),
|
||||
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
|
||||
});
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export const SUMMARY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, endDate: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
} as const;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; status: (typeof STATUSES)[number]; createdAt: Date;
|
||||
event: { id: string; title: string; startDate: Date; endDate: Date };
|
||||
registrationOptions: { quantity: number; priceSnapshot: number | null; eventOption: { name: string; price: number } | null; variant: { name: string } | null }[];
|
||||
payments: { amount: number }[];
|
||||
};
|
||||
|
||||
export function toSummary(r: SummaryRow, now = new Date()): z.input<typeof registrationSummary> {
|
||||
const totalDue = r2(computeRegistrationTotalDue(r, now));
|
||||
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
|
||||
return {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
createdAt: iso(r.createdAt),
|
||||
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
|
||||
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
|
||||
totalDue,
|
||||
amountPaid,
|
||||
balance: r2(totalDue - amountPaid),
|
||||
};
|
||||
}
|
||||
|
||||
/** The owner's registration (admins may see anyone's); others get NOT_FOUND, not a hint it exists. */
|
||||
async function ownRegistration(registrationId: string, user: ApiUser) {
|
||||
const r = await prisma.registration.findUnique({ where: { id: registrationId }, include: SUMMARY_INCLUDE });
|
||||
if (!r || (r.userId !== user.id && user.role !== 'admin')) throw notFound('That registration');
|
||||
return r;
|
||||
}
|
||||
|
||||
const attendee = z.object({
|
||||
eventOptionId: id,
|
||||
variantId: id.nullish(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.string().trim().max(200).nullish(),
|
||||
phone: z.string().trim().max(40).nullish(),
|
||||
dateOfBirth: z.string().max(40).nullish().describe('YYYY-MM-DD (youth events)'),
|
||||
gender: z.string().max(20).nullish(),
|
||||
parentName: z.string().max(200).nullish(),
|
||||
parentEmail: z.string().max(200).nullish(),
|
||||
parentPhone: z.string().max(40).nullish(),
|
||||
});
|
||||
|
||||
export const register = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations',
|
||||
tag: 'Registrations',
|
||||
summary: 'Register for an event',
|
||||
description: [
|
||||
'Signed in, or as a guest for a free event that allows it (`guest` then required), or with a waitlist link (`waitlistToken`).',
|
||||
'Registering again for the same event adds to the existing registration. When nothing is owed it is marked paid and tickets are issued; otherwise pay with `POST /registrations/{id}/checkout`.',
|
||||
'A coupon that was valid when checked but used up a moment later is reported in `couponError`; the registration is still made.',
|
||||
].join('\n\n'),
|
||||
access: 'optional',
|
||||
status: 201,
|
||||
body: z.object({
|
||||
eventId: id,
|
||||
options: z.array(z.object({
|
||||
eventOptionId: id,
|
||||
quantity: z.number().int().min(1).max(1000),
|
||||
variantId: id.nullish(),
|
||||
sessionIds: z.array(id).max(200).optional().describe('"Pick sessions" tickets: the sessions chosen'),
|
||||
})).min(1).max(50),
|
||||
guest: z.object({ name: z.string().trim().min(1).max(200), email: z.email().max(200), phone: z.string().trim().max(40).optional() }).optional(),
|
||||
couponCode: z.string().trim().max(60).optional(),
|
||||
attendees: z.array(attendee).max(500).optional().describe('Names for the places (events that collect them)'),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional().describe('With attendees: tickets to the payer, or to each attendee'),
|
||||
waitlistToken: z.string().max(200).optional(),
|
||||
analyticsSessionId: z.string().max(100).optional(),
|
||||
}),
|
||||
response: single(registrationSummary.extend({
|
||||
isNew: z.boolean().describe('false = added to an existing registration'),
|
||||
couponError: z.string().nullable(),
|
||||
})),
|
||||
async handler({ body, user }) {
|
||||
const { registration, isNew, couponError } = await createRegistration({
|
||||
eventId: body.eventId,
|
||||
options: body.options.map(o => ({ ...o, variantId: o.variantId ?? null })),
|
||||
guestName: body.guest?.name,
|
||||
guestEmail: body.guest?.email,
|
||||
guestPhone: body.guest?.phone,
|
||||
couponCode: body.couponCode,
|
||||
attendees: body.attendees,
|
||||
ticketDelivery: body.ticketDelivery,
|
||||
waitlistToken: body.waitlistToken,
|
||||
analyticsSessionId: body.analyticsSessionId,
|
||||
}, user ? { id: user.id, role: user.role, email: user.email } : null);
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({ where: { id: registration.id }, include: SUMMARY_INCLUDE });
|
||||
return { data: { ...toSummary(fresh), isNew, couponError } };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRegistration = defineRoute({
|
||||
method: 'get',
|
||||
path: '/registrations/:id',
|
||||
tag: 'Registrations',
|
||||
summary: 'One of my registrations',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user }) {
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/cancel',
|
||||
tag: 'Registrations',
|
||||
summary: 'Cancel my registration',
|
||||
description: 'Not after the event has ended, and not once anything has been paid (the church can do that). Its places go to the waitlist.',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user, req }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
await cancelRegistration(params.id, user!, getClientIp(req));
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const checkout = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/checkout',
|
||||
tag: 'Registrations',
|
||||
summary: 'Pay what I owe online',
|
||||
description: [
|
||||
'Starts a payment with the church\'s provider (Yoco or PayFast) and returns where to send the person (`redirectUrl`).',
|
||||
'If an early-bird price ran out since the registration was made, nothing is started: `priceUpdated` is true with the new amount owing — show it, then call again.',
|
||||
].join('\n\n'),
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
body: z.object({
|
||||
donationAmount: z.number().min(0).max(100000).optional().describe('An optional donation added on top, in rand'),
|
||||
provider: z.string().max(40).optional().describe('yoco or payfast (default: the first one switched on)'),
|
||||
successUrl: z.url().max(2000).optional(),
|
||||
cancelUrl: z.url().max(2000).optional(),
|
||||
failureUrl: z.url().max(2000).optional(),
|
||||
}),
|
||||
response: single(z.discriminatedUnion('priceUpdated', [
|
||||
z.object({ priceUpdated: z.literal(true), newBalance: z.number(), message: z.string() }),
|
||||
z.object({ priceUpdated: z.literal(false), checkoutId: z.string(), redirectUrl: z.string(), provider: z.string(), amount: z.number().describe('Charged now, in rand (with any donation)') }),
|
||||
])),
|
||||
async handler({ params, body, user }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const result = await startRegistrationCheckout(params.id, user!, body);
|
||||
if (result.priceUpdated) return { data: { priceUpdated: true as const, newBalance: result.newTotal, message: result.message } };
|
||||
if (!result.redirectUrl) throw new ApiError('INTERNAL', 'The payment provider did not return a payment page.');
|
||||
return { data: { priceUpdated: false as const, checkoutId: result.checkoutId, redirectUrl: result.redirectUrl, provider: result.provider, amount: result.amount } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { pageOf, single } from '../paging';
|
||||
import { id, idParams } from '../schemas';
|
||||
import * as admin from '../../services/registrationAdmin';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- registrations are passed through with their includes */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
};
|
||||
const { notOnSeasonPass } = require('../../utils/seasonPass') as { notOnSeasonPass: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** The v2 request as the registration service's Call. */
|
||||
function callFrom(req: Request, user: ApiUser | null, parts: { params?: object; body?: object; query?: object }): admin.Call {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return {
|
||||
actor: user ? { id: user.id, role: user.role, email: user.email } : null,
|
||||
params: (parts.params ?? {}) as Record<string, string>,
|
||||
body: parts.body ?? {},
|
||||
query: parts.query ?? {},
|
||||
readOnly: !!(req as any).readOnly,
|
||||
ip: getClientIp(req),
|
||||
listWhere: () => campusScope.listWhere(req, 'event'),
|
||||
};
|
||||
}
|
||||
|
||||
const OWNER_OR_STAFF = 'scopeEvent(registration, owner-ok)';
|
||||
const ownerOrStaff = campusScope.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration with everything the dashboards show: items and their prices, payments, attendees, coupons, `totalDueComputed`. */
|
||||
const fullRegistration = z.looseObject({ id, status: z.enum(STATUSES), eventId: id, userId: id, totalDueComputed: z.number().optional() });
|
||||
const message = single(z.looseObject({ message: z.string() }));
|
||||
const lineBody = z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() });
|
||||
const attendeeBody = z.looseObject({ id: id.optional().describe('An attendee already on it (left out = removed)'), eventOptionId: id, variantId: id.nullish(), name: z.string().max(200) });
|
||||
const responsesBody = z.object({
|
||||
responses: z.array(z.object({
|
||||
attendeeId: id.nullish().describe('The named attendee this form is for'),
|
||||
answers: z.record(z.string(), z.unknown()).describe('Field id → answer (stored as text)'),
|
||||
})).max(500),
|
||||
});
|
||||
|
||||
// ─── Owner (or staff) ────────────────────────────────────────────────────────
|
||||
|
||||
export const fullOne = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/full', tag: 'Registrations', summary: 'A registration with everything on it',
|
||||
description: [
|
||||
'Items and prices, payments, coupons and bundles, named attendees, form answers and the total due.',
|
||||
'The owner, a named attendee or staff — or anyone with the link when not signed in (registration ids can\'t be guessed). Attendees\' contact details are only shown to the payer and staff.',
|
||||
].join('\n\n'),
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, response: single(fullRegistration),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getRegistrationById(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const myFull = defineRoute({
|
||||
method: 'get', path: '/me/registrations/full', tag: 'Me', summary: 'My registrations with everything on them',
|
||||
description: 'What the dashboard shows: as `/registrations/{id}/full`, newest first, with the event and its form. Only upcoming ones unless `past=true`.',
|
||||
access: 'user',
|
||||
query: z.object({ past: z.enum(['true', 'false']).default('false') }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ query, user, req }) { return { data: plain(await admin.getUserRegistrations(callFrom(req, user, { query: { showPast: query.past } }))) }; },
|
||||
});
|
||||
|
||||
export const changeItems = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/items', tag: 'Registrations', summary: 'Change the tickets on a registration',
|
||||
description: [
|
||||
'The whole list of items it should have. Added places need stock and are priced now; places already paid for keep their price, and nothing goes below tickets already issued or the amount paid.',
|
||||
'Not for registrations made with a season pass, nor once the event has ended or is closed. Staff can add kids and volunteer places; owners can only keep or lower them.',
|
||||
].join('\n\n'),
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ options: z.array(lineBody.omit({ sessionIds: true })).min(1).max(100) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationOptions(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendInvoice = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/invoice', tag: 'Registrations', summary: 'Send me the invoice again',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']).default('email') }), response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.sendInvoice(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
export const applyCoupon = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Apply a coupon code',
|
||||
description: 'By the owner, or by staff on their behalf (logged). Not on season pass registrations.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ code: z.string().trim().min(1).max(40) }), response: single(fullRegistration.extend({ message: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.applyCoupon(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const removeCoupon = defineRoute({
|
||||
method: 'delete', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Take a coupon code off',
|
||||
description: 'Staff. Its use is freed. Not once a ticket on it has been scanned; unused tickets are withdrawn if it\'s no longer paid.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.removeCoupon(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const setAttendees = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/attendees', tag: 'Registrations', summary: 'Name the people on a registration',
|
||||
description: 'The whole list: attendees left out are removed (not once their ticket has been scanned). `ticketDelivery` says who gets the tickets.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ attendees: z.array(attendeeBody).max(1000), ticketDelivery: z.enum(['payer', 'each']).optional() }),
|
||||
response: single(z.looseObject({ id, ticketDelivery: z.string().nullable(), attendees: z.array(z.looseObject({ id, name: z.string() })) })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateAttendees(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendFormLink = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/attendees/:attendeeId/form-link', tag: 'Registrations', summary: 'Send an attendee their form',
|
||||
description: 'A link to fill in their own attendee form, by email and/or WhatsApp.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: z.object({ id, attendeeId: id }), response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.sendAttendeeFormLink(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const getDraft = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Attendee forms saved part-way',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
response: single(z.object({ data: z.record(z.string(), z.unknown()), updatedAt: z.string().nullable() })),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getFormDraft(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const saveDraft = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Save attendee forms part-way',
|
||||
description: 'Kept for the registration\'s owner (also when staff save it), until the forms are submitted.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ data: z.record(z.string(), z.unknown()) }), response: single(z.object({ message: z.string(), updatedAt: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.saveFormDraft(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const submitForms = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Submit attendee forms',
|
||||
description: 'One response per main ticket at most; each named attendee answers once. Tickets are issued once it\'s paid and the forms are in.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, body: responsesBody,
|
||||
response: single(z.looseObject({ message: z.string(), count: z.number().int().optional() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.submitFormResponses(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const replaceForms = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Replace the attendee forms',
|
||||
description: 'Supervisors: every response on the registration is replaced by these.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, body: responsesBody, response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.replaceFormResponses(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
// ─── Staff ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const list = defineRoute({
|
||||
method: 'get', path: '/registrations', tag: 'Registrations', summary: 'Every registration (staff)',
|
||||
description: 'Newest first, for the events you may see. `unpaid=true` for ones still owing; `userId` for one person\'s.',
|
||||
access: 'staff', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100).describe('At most 200'),
|
||||
unpaid: z.enum(['true', 'false']).default('false'),
|
||||
userId: id.optional(),
|
||||
}),
|
||||
response: z.object({ data: z.array(fullRegistration), meta: z.object({ page: z.number().int(), limit: z.number().int(), total: z.number().int(), pages: z.number().int() }) }),
|
||||
async handler({ query, user, req }) {
|
||||
const out = await admin.getRegistrations(callFrom(req, user, { query: { page: String(query.page), limit: String(query.limit), unpaid: query.unpaid === 'true' ? '1' : '', userId: query.userId } }));
|
||||
return pageOf(plain(out.data), out.total, { page: out.page, limit: out.limit });
|
||||
},
|
||||
});
|
||||
|
||||
export const forEvent = defineRoute({
|
||||
method: 'get', path: '/events/:id/registrations', tag: 'Registrations', summary: 'An event\'s registrations',
|
||||
description: 'Oldest first, with tickets, payments and attendees. `search` matches the name, email or phone.',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
query: z.object({ search: z.string().max(200).optional() }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ params, query, user, req }) { return { data: plain(await admin.getRegistrationsByEvent(callFrom(req, user, { params: { eventId: params.id }, query }))) }; },
|
||||
});
|
||||
|
||||
export const setStatus = defineRoute({
|
||||
method: 'patch', path: '/registrations/:id/status', tag: 'Registrations', summary: 'Set a registration\'s status',
|
||||
description: 'Staff. `paid` issues and sends the tickets; leaving `paid` withdraws unused tickets (refused once one has been scanned); `cancelled` frees its places for the waitlist.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.object({ status: z.enum(STATUSES) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationStatus(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const manual = defineRoute({
|
||||
method: 'post', path: '/registrations/manual', tag: 'Registrations', summary: 'Register someone (at the door, by phone)',
|
||||
description: [
|
||||
'Supervisors. Finds the person by email or phone (refused when those are two different accounts) or makes an account and sends them an activation link (`skipActivationNotice` to not).',
|
||||
'Added to their existing registration for the event if they have one. Free ones are marked paid and get their tickets; otherwise they\'re sent a link to pay.',
|
||||
].join('\n\n'),
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })], status: 201,
|
||||
body: z.looseObject({
|
||||
eventId: id,
|
||||
options: z.array(lineBody).min(1).max(100),
|
||||
user: z.looseObject({ name: z.string().trim().min(1).max(200), email: z.string().max(200).nullish(), phoneNumber: z.string().max(40).nullish(), password: z.string().max(200).optional().describe('Self-service kiosk: the visitor sets it, and the account is active at once') }),
|
||||
notificationPreference: z.string().max(20).nullish().describe('email, whatsapp or both (anything else: worked out from the contact details given)'),
|
||||
skipActivationNotice: z.boolean().optional(),
|
||||
couponCode: z.string().max(40).nullish(),
|
||||
attendees: z.array(attendeeBody).max(1000).optional(),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional(),
|
||||
}),
|
||||
response: single(fullRegistration.extend({ couponError: z.string().optional() })),
|
||||
async handler({ body, user, req }) { return { data: plain(await admin.createManualRegistration(callFrom(req, user, { body }))) }; },
|
||||
});
|
||||
|
||||
export const bulk = defineRoute({
|
||||
method: 'post', path: '/registrations/bulk', tag: 'Registrations', summary: 'Do one thing to many registrations',
|
||||
description: '`markPaid`, `cancel`, `moveOption`, `allocate` or `message`, for up to 500 you may manage. Each is done on its own: the answer lists what worked and what didn\'t.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ body, req }) {
|
||||
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
|
||||
return { data: plain(await runBulk(req, body)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { z } from 'zod';
|
||||
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({})),
|
||||
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() }),
|
||||
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({})),
|
||||
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))) }; },
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import * as setup from '../../services/setup';
|
||||
import { prisma } from '../legacy';
|
||||
import auth from '../../middleware/authMiddleware';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- multer and the scope middleware are JS */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
const uploads = require('../../controllers/uploadController') as { upload: any; uploadLogo: any; uploadFavicon: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// ─── Sections ────────────────────────────────────────────────────────────────
|
||||
|
||||
const section = z.looseObject({
|
||||
id, eventId: id, name: z.string(),
|
||||
allowedOptions: z.array(z.looseObject({ eventOptionId: id, eventOption: z.looseObject({ id, name: z.string() }) })),
|
||||
});
|
||||
|
||||
export const listSections = defineRoute({
|
||||
method: 'get',
|
||||
path: '/sections',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Scanning sections',
|
||||
description: 'Which ticket types may come in at which door, for one event or every event you can see.',
|
||||
access: 'staff',
|
||||
campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({ eventId: id.optional() }),
|
||||
response: z.object({ data: z.array(section.extend({ event: z.object({ id, title: z.string() }) })) }),
|
||||
async handler({ query, req }) {
|
||||
return { data: plain(await setup.listSections(query.eventId, await campusScope.listWhere(req, 'event'))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const createSection = defineRoute({
|
||||
method: 'post',
|
||||
path: '/sections',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a scanning section',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(body.eventId, manage)',
|
||||
middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId, need: 'manage' })],
|
||||
status: 201,
|
||||
body: z.object({ eventId: id, name: z.string().trim().min(1).max(120), allowedOptionIds: z.array(id).max(100).optional() }),
|
||||
response: single(section),
|
||||
async handler({ body }) {
|
||||
return { data: plain(await setup.createSection(body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSection = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/sections/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a scanning section',
|
||||
description: '`allowedOptionIds`, when sent, replaces its ticket types.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(section, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'section', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: z.object({ name: z.string().trim().min(1).max(120).optional(), allowedOptionIds: z.array(id).max(100).optional() }),
|
||||
response: single(section),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.updateSection(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSection = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/sections/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a scanning section',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await setup.deleteSection(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Planned costs ───────────────────────────────────────────────────────────
|
||||
|
||||
const cost = z.object({
|
||||
id, eventId: id, label: z.string(), costType: z.enum(['once_off', 'per_item']), amount: z.number(),
|
||||
eventOptionId: id.nullable(), eventOption: z.object({ id, name: z.string() }).nullable(),
|
||||
paidFromMethod: z.string().nullable().describe('How it was paid out of the takings (cash, card, eft, other)'),
|
||||
notes: z.string().nullable(), createdAt: isoDate, updatedAt: isoDate.optional(),
|
||||
});
|
||||
const costBody = z.object({
|
||||
label: z.string().trim().min(1).max(200),
|
||||
costType: z.enum(['once_off', 'per_item']).describe('per_item = per ticket sold of `eventOptionId`'),
|
||||
amount: z.union([z.number().min(0), z.string().max(20)]),
|
||||
eventOptionId: id.nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
paidFromMethod: z.enum(['cash', 'card', 'eft', 'other']).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listCosts = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/costs',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'An event\'s planned costs',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: idParams,
|
||||
response: z.object({ data: z.array(cost) }),
|
||||
async handler({ params }) {
|
||||
return { data: plain(await setup.listCosts(params.id)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const createCost = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/costs',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a planned cost',
|
||||
description: 'Admins. Not on a cashed-up event.',
|
||||
access: 'admin',
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: costBody,
|
||||
response: single(cost),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.createCost(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateCost = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/costs/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a planned cost',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
body: costBody.partial(),
|
||||
response: single(cost),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.updateCost(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCost = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/costs/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a planned cost',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await setup.deleteCost(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Image uploads ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Runs a multer step for field "image", keeping any error on the request. */
|
||||
const receive = (m: any): RequestHandler => (req, res, next) => m.single('image')(req, res, (e: Error | undefined) => {
|
||||
if (e) (req as Request & { multerError?: Error }).multerError = e;
|
||||
next();
|
||||
});
|
||||
|
||||
/** Branding images: admins — or anyone during first-time setup, before any account exists. */
|
||||
const brandingAccess: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
if (await prisma.user.count() === 0) return next();
|
||||
} catch { /* fall through to the admin check */ }
|
||||
auth.protect(req, res, (e?: unknown) => (e ? next(e) : auth.admin(req, res, next)));
|
||||
};
|
||||
|
||||
const uploaded = (folder: string) => async ({ req }: { req: Request }) => {
|
||||
const r = req as Request & { multerError?: Error; file?: { filename: string } };
|
||||
if (r.multerError) throw new ApiError('VALIDATION_FAILED', `Upload failed: ${r.multerError.message}`, { image: r.multerError.message });
|
||||
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { image: 'Choose an image' });
|
||||
const path = `/uploads/${folder}/${r.file.filename}`;
|
||||
return { data: { path, url: absoluteUrl(req, path)! } };
|
||||
};
|
||||
const uploadResult = single(z.object({ path: z.string().describe('What to save on the event or setting'), url: z.string().describe('The same, as a full address') }));
|
||||
const IMAGE = 'A JPEG, PNG, GIF or WebP image';
|
||||
const LOGO = 'A JPEG, PNG, WebP or SVG image';
|
||||
|
||||
export const uploadEventImage = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/event-image',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload an event picture',
|
||||
description: 'Returns the path to save as the event\'s `picture`.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'campusIndependent (only stores the file; the event is checked when it is saved)',
|
||||
middleware: [receive(uploads.upload)],
|
||||
upload: { field: 'image', description: IMAGE },
|
||||
response: uploadResult,
|
||||
handler: uploaded('events'),
|
||||
});
|
||||
|
||||
export const uploadLogo = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/logo',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload the site logo',
|
||||
description: 'Admins — or anyone during first-time setup, before any account exists.',
|
||||
access: 'public',
|
||||
middleware: [brandingAccess, receive(uploads.uploadLogo)],
|
||||
upload: { field: 'image', description: LOGO },
|
||||
response: uploadResult,
|
||||
handler: uploaded('branding'),
|
||||
});
|
||||
|
||||
export const uploadFavicon = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/favicon',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload the site icon (favicon)',
|
||||
description: 'Admins — or anyone during first-time setup, before any account exists.',
|
||||
access: 'public',
|
||||
middleware: [brandingAccess, receive(uploads.uploadFavicon)],
|
||||
upload: { field: 'image', description: 'An ICO, PNG or SVG image' },
|
||||
response: uploadResult,
|
||||
handler: uploaded('branding'),
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Request, RequestHandler, Response, Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from './errors';
|
||||
import auth from '../middleware/authMiddleware';
|
||||
|
||||
/**
|
||||
* Every /api/v2 endpoint is declared with `defineRoute`: method, path, who may call it, and zod
|
||||
* schemas for the params, query, body and response. The same declaration
|
||||
* - validates the request (400 VALIDATION_FAILED with per-field messages),
|
||||
* - checks the response against its schema and strips anything it doesn't list (so a field
|
||||
* can't leak by being added to a Prisma include), and
|
||||
* - becomes the OpenAPI document (openapi.ts) — the docs can't drift from the code.
|
||||
*/
|
||||
|
||||
export type Access = 'public' | 'optional' | 'user' | 'staff' | 'supervisor' | 'admin';
|
||||
|
||||
/** The signed-in person, as the auth middleware puts it on the request. */
|
||||
export type ApiUser = { id: string; name: string; email: string; role: 'admin' | 'supervisor' | 'staff' | 'user' };
|
||||
|
||||
export type Ctx<P, Q, B> = {
|
||||
params: P;
|
||||
query: Q;
|
||||
body: B;
|
||||
user: ApiUser | null;
|
||||
req: Request;
|
||||
res: Response;
|
||||
};
|
||||
|
||||
type Schema = z.ZodType;
|
||||
type Infer<S> = S extends z.ZodType ? z.infer<S> : undefined;
|
||||
|
||||
export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema> = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
/** Express-style, relative to /api/v2: "/events/:id". */
|
||||
path: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
tag: string;
|
||||
access: Access;
|
||||
/**
|
||||
* Staff and supervisor routes must say how they treat campus-scoped staff (see
|
||||
* utils/campusScope.js and tests/campusScopeRoutes.test.js) — e.g. "scopeEvent(params.id)".
|
||||
*/
|
||||
campusScope?: string;
|
||||
params?: P;
|
||||
query?: Q;
|
||||
body?: B;
|
||||
response: R;
|
||||
/** HTTP status on success (default 200). */
|
||||
status?: number;
|
||||
/** Runs after the access check, e.g. a rate limiter. */
|
||||
middleware?: RequestHandler[];
|
||||
/** A multipart upload (the file in `field`, saved by a multer step in `middleware`). */
|
||||
upload?: { field: string; description: string };
|
||||
/**
|
||||
* The answer is a file, not JSON: the handler returns `{ data: { contentType, filename, body } }`
|
||||
* and `response` is `fileResponse`. `contentType` here is what the docs show.
|
||||
*/
|
||||
file?: { contentType: string };
|
||||
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyRoute = RouteDef<any, any, any, any>;
|
||||
|
||||
export const routes: AnyRoute[] = [];
|
||||
|
||||
const ACCESS: Record<Access, RequestHandler[]> = {
|
||||
public: [],
|
||||
optional: [auth.optionalAuth],
|
||||
user: [auth.protect],
|
||||
staff: [auth.protect, auth.staff],
|
||||
supervisor: [auth.protect, auth.supervisor],
|
||||
admin: [auth.protect, auth.admin],
|
||||
};
|
||||
|
||||
export function defineRoute<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema>(def: RouteDef<P, Q, B, R>): RouteDef<P, Q, B, R> {
|
||||
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;
|
||||
}
|
||||
|
||||
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
|
||||
(schema ? schema.parse(value) : undefined) as Infer<S>;
|
||||
|
||||
/** Adds every defined route to the router. */
|
||||
export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
for (const def of list) {
|
||||
const run: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const ctx = {
|
||||
params: parse(def.params, req.params),
|
||||
query: parse(def.query, req.query),
|
||||
body: parse(def.body, req.body ?? {}),
|
||||
user: (req as Request & { user?: ApiUser }).user ?? null,
|
||||
req,
|
||||
res,
|
||||
};
|
||||
const result = await def.handler(ctx);
|
||||
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 | 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);
|
||||
return;
|
||||
}
|
||||
res.status(def.status ?? 200).json(checked.data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.union([z.string(), z.instanceof(Buffer)]) }) });
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** Shared pieces of v2 schemas. Dates go out as ISO 8601 strings (UTC). */
|
||||
|
||||
export const isoDate = z.iso.datetime();
|
||||
export const id = z.string().min(1).max(64);
|
||||
export const idParams = z.object({ id: id.describe('The id') });
|
||||
|
||||
/** A Date (or date string) as ISO 8601, for building responses. */
|
||||
export function iso(d: Date | string): string;
|
||||
export function iso(d: Date | string | null | undefined): string | null;
|
||||
export function iso(d: Date | string | null | undefined): string | null {
|
||||
return d == null ? null : new Date(d).toISOString();
|
||||
}
|
||||
|
||||
/** A stored picture path ("/uploads/…") as an absolute URL on the host the request came to. */
|
||||
export function absoluteUrl(req: Request, url: string | null | undefined): string | null {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//.test(raw)) return raw;
|
||||
return `${req.protocol}://${req.get('host')}${raw.startsWith('/') ? '' : '/'}${raw}`;
|
||||
}
|
||||
|
||||
export const campusRef = z.object({ id, name: z.string(), slug: z.string() });
|
||||
export const seriesRef = z.object({ id, name: z.string(), slug: z.string() });
|
||||
export const eventRef = z.object({ id, title: z.string(), startDate: isoDate, endDate: isoDate });
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
|
||||
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
|
||||
*
|
||||
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
|
||||
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
|
||||
* v1's `code` becomes `reason`.
|
||||
*
|
||||
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
|
||||
* 1.51.10 did) can follow later without changing the v2 API.
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
import { basename } from 'path';
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
|
||||
/** `url` (with its query string) is for v1 routers, which route by it. */
|
||||
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown; url?: string };
|
||||
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
|
||||
|
||||
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
|
||||
class RecordingResponse extends Writable {
|
||||
statusCode = 200;
|
||||
headersSent = false;
|
||||
locals: Record<string, unknown> = {};
|
||||
readonly headers: Record<string, string> = {};
|
||||
private chunks: Buffer[] = [];
|
||||
json?: (v: unknown) => this;
|
||||
answer?: V1Answer;
|
||||
private done!: (a: V1Answer) => void;
|
||||
readonly finished: Promise<V1Answer>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.finished = new Promise(resolve => { this.done = resolve; });
|
||||
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
|
||||
}
|
||||
|
||||
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
|
||||
override _final(cb: () => void) { this.finishWith({}); cb(); }
|
||||
|
||||
private finishWith(extra: Partial<V1Answer>): this {
|
||||
if (this.answer) return this;
|
||||
this.headersSent = true;
|
||||
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
|
||||
this.done(this.answer);
|
||||
return this;
|
||||
}
|
||||
|
||||
status(code: number) { this.statusCode = code; return this; }
|
||||
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
|
||||
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
|
||||
set(name: string | Record<string, unknown>, value?: unknown) {
|
||||
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
|
||||
else this.setHeader(name, value);
|
||||
return this;
|
||||
}
|
||||
header(name: string, value?: unknown) { return this.set(name, value); }
|
||||
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
|
||||
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
|
||||
send(body?: unknown) {
|
||||
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
|
||||
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
|
||||
return this.finishWith({});
|
||||
}
|
||||
// A handler that ends without writing anything still answers.
|
||||
override end(...args: any[]) {
|
||||
const [chunk] = args;
|
||||
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
|
||||
this.finishWith({});
|
||||
return this;
|
||||
}
|
||||
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
|
||||
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
|
||||
const name = typeof filename === 'string' ? filename : basename(filePath);
|
||||
readFile(filePath).then(buf => {
|
||||
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
|
||||
this.setHeader('content-disposition', `attachment; filename="${name}"`);
|
||||
this.chunks.push(buf);
|
||||
this.finishWith({});
|
||||
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
sendFile(filePath: string) {
|
||||
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
|
||||
return this;
|
||||
}
|
||||
redirect(a: number | string, b?: string) {
|
||||
const url = typeof a === 'string' ? a : b!;
|
||||
this.statusCode = typeof a === 'number' ? a : 302;
|
||||
return this.finishWith({ redirect: url });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
|
||||
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
|
||||
* campus scope, read-only state) is the real request's.
|
||||
*/
|
||||
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
|
||||
const fake = Object.create(req) as Request;
|
||||
for (const key of ['params', 'query', 'body', 'url'] as const) {
|
||||
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
|
||||
}
|
||||
const res = new RecordingResponse();
|
||||
const chain = Array.isArray(handlers) ? handlers : [handlers];
|
||||
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
|
||||
let advanced = false;
|
||||
const next = (err?: unknown) => {
|
||||
advanced = true;
|
||||
if (err) return reject(err);
|
||||
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
|
||||
// Nothing answered (e.g. a v1 router with no matching route).
|
||||
else reject(Object.assign(new Error('There is no such API endpoint.'), { statusCode: 404 }));
|
||||
};
|
||||
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
|
||||
});
|
||||
await Promise.race([step(0).then(() => res.finished), res.finished]);
|
||||
return res.answer!;
|
||||
}
|
||||
|
||||
/** A v1 error answer as something v2's error handler understands. */
|
||||
function v1Error(a: V1Answer): Error {
|
||||
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
|
||||
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
|
||||
const e = new Error(text) as Error & { statusCode: number; code?: string };
|
||||
e.statusCode = a.status;
|
||||
if (typeof j.code === 'string') e.code = j.code;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** `{ data: <anything> }` — for v1 answers documented by their description. */
|
||||
export const anyData = z.object({ data: z.unknown() });
|
||||
export const listData = z.object({ data: z.array(z.looseObject({})) });
|
||||
export const objectData = z.object({ data: z.looseObject({}) });
|
||||
|
||||
type V1RouteDef = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
path: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
access: Access;
|
||||
campusScope?: string;
|
||||
middleware?: RequestHandler[];
|
||||
params?: z.ZodType;
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response?: z.ZodType;
|
||||
status?: number;
|
||||
file?: { contentType: string };
|
||||
upload?: { field: string; description: string };
|
||||
/** The v1 handler (or middleware chain ending in one). */
|
||||
v1: RequestHandler | RequestHandler[];
|
||||
/**
|
||||
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
|
||||
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
|
||||
*/
|
||||
map?: (ctx: Ctx<any, any, any>) => Parts;
|
||||
};
|
||||
|
||||
/** A v2 route answered by a v1 handler. */
|
||||
export function v1Route(def: V1RouteDef) {
|
||||
const { v1, map, response, ...rest } = def;
|
||||
return defineRoute({
|
||||
...rest,
|
||||
response: def.file ? fileResponse : (response ?? anyData),
|
||||
async handler(ctx) {
|
||||
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
|
||||
const a = await runV1(v1, ctx.req, parts);
|
||||
if (a.status >= 400) throw v1Error(a);
|
||||
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
|
||||
if (def.file) {
|
||||
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
|
||||
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
|
||||
}
|
||||
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,40 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const { auditLogWhere } = require('../src/utils/adminAudit');
|
||||
|
||||
describe('auditLogWhere', () => {
|
||||
test('no filters → everything', () => {
|
||||
expect(auditLogWhere({})).toEqual({});
|
||||
});
|
||||
|
||||
test('a "to" date includes that whole day', () => {
|
||||
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
|
||||
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
|
||||
expect(where.createdAt.lte).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a full timestamp is used as given', () => {
|
||||
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
|
||||
});
|
||||
|
||||
test('action and role filter exactly', () => {
|
||||
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
|
||||
});
|
||||
|
||||
test('search looks at the person, the target and the IP', () => {
|
||||
const where = auditLogWhere({ search: ' #ab12 ' });
|
||||
expect(where.OR).toEqual([
|
||||
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
|
||||
{ targetId: { startsWith: 'ab12' } },
|
||||
{ ip: { startsWith: '#ab12' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a blank search is ignored', () => {
|
||||
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const mockDb = {
|
||||
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const filters = require('../src/utils/savedFilters');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('only simple filter values are kept', () => {
|
||||
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
|
||||
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
|
||||
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
|
||||
});
|
||||
|
||||
test('pages and names are checked', async () => {
|
||||
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
|
||||
expect(() => filters.cleanName(' ')).toThrow('name');
|
||||
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
|
||||
});
|
||||
|
||||
test('saving a default clears the old default; saving the same name replaces it', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
|
||||
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
|
||||
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
|
||||
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
|
||||
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('at most 30 per page', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue(null);
|
||||
mockDb.savedFilter.count.mockResolvedValue(30);
|
||||
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
|
||||
});
|
||||
|
||||
test('someone else\'s saved filter is "not found"', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
|
||||
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
// /api/v2 (1.51.0): the route declarations validate requests, shape responses and errors the same
|
||||
// way everywhere, and produce the OpenAPI document.
|
||||
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/campuses', () => ({
|
||||
campusesEnabled: jest.fn().mockResolvedValue(false),
|
||||
resolveCampusFilter: jest.fn(async (v: string) => v),
|
||||
campusWhere: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// Signed in as whoever the test says (the real middleware checks JWTs against the database).
|
||||
let signedIn: { id: string; role: string } | null = null;
|
||||
jest.mock('../src/middleware/authMiddleware', () => {
|
||||
const deny = (status: number, message: string) => (_req: unknown, res: { status(n: number): void }, next: (e: Error) => void) => { res.status(status); next(new Error(message)); };
|
||||
return {
|
||||
protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)),
|
||||
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
|
||||
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
|
||||
staff: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor', 'staff'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as staff')(req, res, next as (e: Error) => void)),
|
||||
supervisor: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as a supervisor')(req, res, next as (e: Error) => void)),
|
||||
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
};
|
||||
});
|
||||
|
||||
// The services are tested through v1 (e2e); here only how v2 calls them and maps their answers.
|
||||
const mockServices = { createRegistration: jest.fn(), cancelRegistration: jest.fn(), startRegistrationCheckout: jest.fn() };
|
||||
jest.mock('../src/services/registrations', () => ({ createRegistration: (...a: unknown[]) => mockServices.createRegistration(...a), cancelRegistration: (...a: unknown[]) => mockServices.cancelRegistration(...a) }));
|
||||
jest.mock('../src/services/payments', () => ({ startRegistrationCheckout: (...a: unknown[]) => mockServices.startRegistrationCheckout(...a) }));
|
||||
const mockAuth = { signUp: jest.fn(), logIn: jest.fn(), verifyLoginCode: jest.fn(), updateProfile: jest.fn() };
|
||||
jest.mock('../src/services/auth', () => ({
|
||||
signUp: (...a: unknown[]) => mockAuth.signUp(...a), logIn: (...a: unknown[]) => mockAuth.logIn(...a),
|
||||
verifyLoginCode: (...a: unknown[]) => mockAuth.verifyLoginCode(...a), updateProfile: (...a: unknown[]) => mockAuth.updateProfile(...a),
|
||||
}));
|
||||
const mockCheckin = { previewScan: jest.fn(), scanTicket: jest.fn() };
|
||||
jest.mock('../src/services/checkin', () => ({ previewScan: (...a: unknown[]) => mockCheckin.previewScan(...a), scanTicket: (...a: unknown[]) => mockCheckin.scanTicket(...a) }));
|
||||
const mockScope = { scope: null as object | null, campusId: 'north' as string | null, blockEvent: false };
|
||||
jest.mock('../src/utils/campusScope', () => ({
|
||||
scopeFor: async () => mockScope.scope,
|
||||
eventOf: async () => ({ campusId: mockScope.campusId, campusName: 'North' }),
|
||||
canView: (scope: { campusIds: string[] }, campusId: string | null) => !campusId || scope.campusIds.includes(campusId),
|
||||
// Route-level checks: refuse like v1 does (v1 shape — the v2 wrapper isn't in this test app).
|
||||
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)])));
|
||||
|
||||
const mockDetails = {
|
||||
getEventDetail: jest.fn(), eventCalendar: jest.fn(), eventByAlias: jest.fn(), createOption: jest.fn(), updateOption: jest.fn(), deleteOption: jest.fn(),
|
||||
createVariant: jest.fn(), updateVariant: jest.fn(), deleteVariant: jest.fn(), listAttachments: jest.fn(), recordAttachment: jest.fn(), deleteAttachment: jest.fn(),
|
||||
attachmentsStatus: jest.fn(), syncAttachmentManifests: jest.fn(),
|
||||
};
|
||||
jest.mock('../src/services/eventDetails', () => Object.fromEntries(Object.keys(mockDetails).map(k => [k, (...a: unknown[]) => (mockDetails as Record<string, jest.Mock>)[k](...a)])));
|
||||
// The upload step: pretend multer saved the file (or refused it).
|
||||
const mockUpload = { file: null as object | null, error: null as Error | null };
|
||||
jest.mock('../src/controllers/eventController', () => ({
|
||||
receiveAttachment: (req: { file?: object; multerError?: Error }, _res: unknown, next: () => void) => { if (mockUpload.error) req.multerError = mockUpload.error; else if (mockUpload.file) req.file = mockUpload.file; next(); },
|
||||
}));
|
||||
|
||||
const mockSetup = { listSections: jest.fn(), createSection: jest.fn(), updateSection: jest.fn(), deleteSection: jest.fn(), listCosts: jest.fn(), createCost: jest.fn(), updateCost: jest.fn(), deleteCost: jest.fn() };
|
||||
jest.mock('../src/services/setup', () => Object.fromEntries(Object.keys(mockSetup).map(k => [k, (...a: unknown[]) => (mockSetup as Record<string, jest.Mock>)[k](...a)])));
|
||||
// Image uploads: a stand-in for multer that "saves" mockImage.
|
||||
const mockImage = { file: null as object | null };
|
||||
jest.mock('../src/controllers/uploadController', () => {
|
||||
const m = { single: () => (req: { file?: object }, _res: unknown, cb: () => void) => { if (mockImage.file) req.file = mockImage.file; cb(); } };
|
||||
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';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v2', v2Router());
|
||||
|
||||
const event = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
|
||||
location: 'Camp Joy', price: 600, picture: '/uploads/events/camp.jpg', requiresRegistration: true, scheduleType: 'single',
|
||||
campus: null, series: { id: 's1', name: 'Camps', slug: 'camps' }, ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => { signedIn = null; jest.clearAllMocks(); });
|
||||
|
||||
describe('GET /api/v2/events', () => {
|
||||
test('a page of public events, with meta and ISO dates', async () => {
|
||||
mockDb.event.findMany.mockResolvedValue([event()]);
|
||||
mockDb.event.count.mockResolvedValue(31);
|
||||
const res = await request(app).get('/api/v2/events?page=2&limit=10&q=camp');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.meta).toEqual({ page: 2, limit: 10, total: 31, pages: 4 });
|
||||
expect(res.body.data[0]).toMatchObject({ id: 'e1', startDate: '2026-12-04T16:00:00.000Z', series: { slug: 'camps' } });
|
||||
expect(res.body.data[0].pictureUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/uploads\/events\/camp\.jpg$/);
|
||||
const args = mockDb.event.findMany.mock.calls[0][0];
|
||||
expect(args).toMatchObject({ skip: 10, take: 10 });
|
||||
expect(args.where.AND).toEqual(expect.arrayContaining([{ isActive: true, isHidden: false, goLiveAt: { lte: expect.any(Date) } }, { title: { contains: 'camp', mode: 'insensitive' } }]));
|
||||
});
|
||||
|
||||
test('fields the schema does not list are never sent', async () => {
|
||||
mockDb.event.findMany.mockResolvedValue([event({ cashupStatus: 'closed', createdById: 'u9' })]);
|
||||
mockDb.event.count.mockResolvedValue(1);
|
||||
const res = await request(app).get('/api/v2/events');
|
||||
expect(Object.keys(res.body.data[0]).sort()).toEqual(['campus', 'endDate', 'id', 'location', 'pictureUrl', 'price', 'requiresRegistration', 'scheduleType', 'series', 'startDate', 'title']);
|
||||
});
|
||||
|
||||
test('a bad query is a VALIDATION_FAILED error naming the field', async () => {
|
||||
const res = await request(app).get('/api/v2/events?limit=500&page=0');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe('VALIDATION_FAILED');
|
||||
expect(Object.keys(res.body.error.fields).sort()).toEqual(['limit', 'page']);
|
||||
expect(mockDb.event.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v2/events/:id', () => {
|
||||
test('leaves out kids and volunteer tickets and shows the early-bird price now', async () => {
|
||||
mockDb.event.findFirst.mockResolvedValue({
|
||||
...event(), description: 'A weekend away', registrationDeadline: null, kidsOptionId: 'kids', volunteerOptionId: null,
|
||||
eventOptions: [
|
||||
{ id: 'o1', name: 'Camper', price: 600, isMainTicket: true, sessionMode: 'all', earlyBirdTiers: [{ id: 't', price: 500, deadline: new Date(Date.now() + 864e5), stockLimit: 0 }] },
|
||||
{ id: 'kids', name: 'Kids check-in', price: 0, isMainTicket: false, sessionMode: 'all', earlyBirdTiers: [] },
|
||||
],
|
||||
sessions: [],
|
||||
});
|
||||
const res = await request(app).get('/api/v2/events/e1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.ticketOptions).toEqual([{ id: 'o1', name: 'Camper', price: 600, currentPrice: 500, isMainTicket: true, sessionMode: 'all' }]);
|
||||
});
|
||||
|
||||
test('an unknown or private event is NOT_FOUND', async () => {
|
||||
mockDb.event.findFirst.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/v2/events/nope');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: "That event wasn't found." } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('me', () => {
|
||||
test('signed out is UNAUTHENTICATED in the v2 shape', async () => {
|
||||
const res = await request(app).get('/api/v2/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error.code).toBe('UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
test('my registrations with totals and what is owing', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findMany.mockResolvedValue([{
|
||||
id: 'r1', status: 'partial_paid', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
|
||||
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
|
||||
registrationOptions: [{ quantity: 2, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
|
||||
payments: [{ amount: 500, createdAt: new Date() }],
|
||||
}]);
|
||||
mockDb.registration.count.mockResolvedValue(1);
|
||||
const res = await request(app).get('/api/v2/me/registrations');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data[0]).toMatchObject({ id: 'r1', totalDue: 1200, amountPaid: 500, balance: 700, items: [{ ticket: 'Camper', variant: null, quantity: 2, unitPrice: 600 }] });
|
||||
expect(mockDb.registration.findMany.mock.calls[0][0].where).toMatchObject({ userId: 'u1', status: { not: 'cancelled' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the API description', () => {
|
||||
test('openapi.json is for admins', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/openapi.json')).body.error.code).toBe('FORBIDDEN');
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).get('/api/v2/openapi.json');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.openapi).toBe('3.1.0');
|
||||
});
|
||||
|
||||
test('every route is described, with its parameters, auth and response', () => {
|
||||
const doc = buildOpenApi({ version: 'test' });
|
||||
for (const r of routes) {
|
||||
const op = (doc.paths as Record<string, Record<string, Record<string, unknown>>>)[r.path.replace(/:(\w+)/g, '{$1}')][r.method];
|
||||
expect(op).toBeDefined();
|
||||
expect(op.responses).toHaveProperty(String(r.status ?? 200));
|
||||
}
|
||||
const list = doc.paths['/events'].get as { parameters: { name: string; in: string }[]; security?: unknown };
|
||||
expect(list.parameters.map(p => p.name)).toEqual(['page', 'limit', 'q', 'campus', 'series']);
|
||||
expect(list.security).toBeUndefined();
|
||||
expect((doc.paths['/me'].get as { security: unknown }).security).toEqual([{ bearerAuth: [] }]);
|
||||
expect((doc.paths['/events/{id}'].get as { parameters: { name: string; in: string; required: boolean }[] }).parameters[0]).toMatchObject({ name: 'id', in: 'path', required: true });
|
||||
});
|
||||
|
||||
test('an unknown v2 path is a v2 NOT_FOUND', async () => {
|
||||
const res = await request(app).get('/api/v2/nothing-here');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registrations', () => {
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'r1', userId: 'u1', status: 'pending', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
|
||||
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
|
||||
registrationOptions: [{ quantity: 1, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
|
||||
payments: [], ...over,
|
||||
});
|
||||
|
||||
test('registering passes a guest through and answers 201 with what is owing', async () => {
|
||||
mockServices.createRegistration.mockResolvedValue({ registration: { id: 'r1' }, isNew: true, couponError: null });
|
||||
mockDb.registration.findUniqueOrThrow.mockResolvedValue(row());
|
||||
const res = await request(app).post('/api/v2/registrations').send({
|
||||
eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }], guest: { name: 'Thandi', email: 'thandi@example.com' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', isNew: true, couponError: null, totalDue: 600, balance: 600 });
|
||||
expect(mockServices.createRegistration).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e1', guestName: 'Thandi', guestEmail: 'thandi@example.com' }), null);
|
||||
});
|
||||
|
||||
test('a bad body never reaches the service', async () => {
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 0 }], guest: { name: 'T', email: 'not-an-email' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(Object.keys(res.body.error.fields).sort()).toEqual(['guest.email', 'options.0.quantity']);
|
||||
expect(mockServices.createRegistration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the service's errors keep their meaning", async () => {
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(401, 'Authentication required for this event'));
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toEqual({ code: 'UNAUTHENTICATED', message: 'Authentication required for this event' });
|
||||
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(400, '"Camper" is sold out or does not have enough stock (0 remaining).'));
|
||||
const sold = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(sold.body.error.code).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
test("someone else's registration is NOT_FOUND, and can't be cancelled or paid", async () => {
|
||||
signedIn = { id: 'u2', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
for (const [method, path] of [['get', '/api/v2/registrations/r1'], ['post', '/api/v2/registrations/r1/cancel'], ['post', '/api/v2/registrations/r1/checkout']] as const) {
|
||||
const res = await request(app)[method](path).send({});
|
||||
expect([path, res.status]).toEqual([path, 404]);
|
||||
}
|
||||
expect(mockServices.cancelRegistration).not.toHaveBeenCalled();
|
||||
expect(mockServices.startRegistrationCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancelling my registration', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValueOnce(row()).mockResolvedValueOnce(row({ status: 'cancelled' }));
|
||||
mockServices.cancelRegistration.mockResolvedValue({});
|
||||
const res = await request(app).post('/api/v2/registrations/r1/cancel');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('checkout: the payment page, or the new balance when an early-bird price ran out', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650, registration: {} });
|
||||
const pay = await request(app).post('/api/v2/registrations/r1/checkout').send({ donationAmount: 50 });
|
||||
expect(pay.body.data).toEqual({ priceUpdated: false, checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650 });
|
||||
expect(mockServices.startRegistrationCheckout).toHaveBeenCalledWith('r1', signedIn, { donationAmount: 50 });
|
||||
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ priceUpdated: true, newTotal: 700, message: 'Prices changed' });
|
||||
const changed = await request(app).post('/api/v2/registrations/r1/checkout').send({});
|
||||
expect(changed.body.data).toEqual({ priceUpdated: true, newBalance: 700, message: 'Prices changed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the frontend types', () => {
|
||||
test('frontend/src/lib/api-v2/openapi.json is up to date (run `npm run api:types` in the repo root)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { OPENAPI_FILE, currentDocument } = require('../scripts/openapi');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const saved = require('fs').readFileSync(OPENAPI_FILE, 'utf8').replace(/\r\n/g, '\n');
|
||||
expect(saved).toBe(currentDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe('accounts', () => {
|
||||
const sess = { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user', token: 'jwt', newsPromptPending: true, noticePromptPending: false };
|
||||
|
||||
test('log in: a token, or a code to finish with', async () => {
|
||||
mockAuth.logIn.mockResolvedValueOnce({ status: 'signed_in', session: sess });
|
||||
const ok = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
|
||||
expect(ok.body.data).toEqual({ status: 'signed_in', token: 'jwt', user: { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user' }, newsPromptPending: true, noticePromptPending: false });
|
||||
|
||||
mockAuth.logIn.mockResolvedValueOnce({ status: 'code_required', challenge: { challengeId: 'c1', method: 'email', destination: 't***@x.co' }, enrolling: true });
|
||||
const code = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
|
||||
expect(code.body.data).toEqual({ status: 'code_required', challengeId: 'c1', method: 'email', destination: 't***@x.co', enrolling: true });
|
||||
// The token never comes with a code challenge.
|
||||
expect(code.body.data.token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a wrong password is UNAUTHENTICATED and a locked account RATE_LIMITED', async () => {
|
||||
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(401, 'Invalid email or password', undefined, { v1Status: 400 }));
|
||||
expect((await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' })).body.error.code).toBe('UNAUTHENTICATED');
|
||||
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(429, 'Too many attempts. Try again in 5 minute(s).'));
|
||||
const locked = await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' });
|
||||
expect([locked.status, locked.body.error.code]).toEqual([429, 'RATE_LIMITED']);
|
||||
});
|
||||
|
||||
test('sign up needs the terms accepted and a password of 8+', async () => {
|
||||
const bad = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'short' });
|
||||
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['acceptTerms', 'password']);
|
||||
mockAuth.signUp.mockResolvedValue({ id: 'u9', name: 'T', email: 't@x.co', role: 'user', token: 'jwt9' });
|
||||
const ok = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'long-enough', acceptTerms: true, newsOptIn: true });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body.data).toMatchObject({ status: 'signed_in', token: 'jwt9' });
|
||||
expect(mockAuth.signUp).toHaveBeenCalledWith(expect.objectContaining({ newsOptIn: true }), expect.any(Object));
|
||||
});
|
||||
|
||||
test('PATCH /me changes only what is sent and hands back a fresh token', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockAuth.updateProfile.mockResolvedValue({ user: { id: 'u1', name: 'New', email: 't@x.co', phoneNumber: null, role: 'user', twoFactorEnabled: false, notificationPreference: 'email', homeCampusId: null, createdAt: new Date('2026-01-01T00:00:00Z'), tokenVersion: 0 }, token: 'jwt2' });
|
||||
const res = await request(app).patch('/api/v2/me').send({ name: 'New' });
|
||||
expect(res.body).toMatchObject({ data: { name: 'New', phone: null }, token: 'jwt2' });
|
||||
expect(res.body.data.tokenVersion).toBeUndefined();
|
||||
expect(mockAuth.updateProfile).toHaveBeenCalledWith('u1', { name: 'New', phoneNumber: undefined }, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-in', () => {
|
||||
const ticket = { id: 't1', quantity: 2, isUsed: false, eventId: 'e1', event: { title: 'Youth Camp' }, registrationOption: { eventOption: { id: 'o1', name: 'Camper', isMainTicket: true } }, usages: [] };
|
||||
afterEach(() => { mockScope.scope = null; });
|
||||
|
||||
test('staff only', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.error.code).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
test('every normal result is a 200 with an outcome', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'checked_in', message: 'Ticket scanned successfully (1 of 2 redeemed, 1 remaining)', session: null, ticketUsage: {}, ticket, qtyRedeemed: 1, totalRedeemed: 1, remaining: 1 });
|
||||
const ok = await request(app).post('/api/v2/checkin/QR1').send({ people: 1, eventId: 'e1' });
|
||||
expect(ok.body.data).toEqual({ outcome: 'checked_in', message: expect.any(String), ticket: { id: 't1', eventId: 'e1', eventTitle: 'Youth Camp', ticketName: 'Camper', quantity: 2 }, checkedIn: 1, remaining: 1, session: null, consentStatus: null, canOverride: false });
|
||||
expect(mockCheckin.scanTicket).toHaveBeenCalledWith('QR1', signedIn, expect.objectContaining({ qty: 1, eventId: 'e1' }), expect.anything());
|
||||
|
||||
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'consent_required', message: 'Parental consent is missing', consentStatus: 'missing', canOverride: false, ticket });
|
||||
const consent = await request(app).post('/api/v2/checkin/QR1').send({});
|
||||
expect(consent.status).toBe(200);
|
||||
expect(consent.body.data).toMatchObject({ outcome: 'consent_required', checkedIn: 0, consentStatus: 'missing' });
|
||||
|
||||
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'transferred', message: 'Given to Sipho' });
|
||||
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.data).toMatchObject({ outcome: 'transferred', ticket: null });
|
||||
});
|
||||
|
||||
test("campus-scoped staff can't check in another campus's ticket", async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockScope.scope = { campusIds: ['south'] };
|
||||
const res = await request(app).post('/api/v2/checkin/QR1').send({});
|
||||
expect(res.body.error).toEqual({ code: 'FORBIDDEN', message: 'This ticket is for an event at North.' });
|
||||
expect(mockCheckin.scanTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the preview names the holder and what is left', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockCheckin.previewScan.mockResolvedValue({ outcome: 'found', ticket: { ...ticket, consentStatus: 'not_required', remaining: 2, session: null, sessionError: null, registrationOption: { id: 'ro1', eventOption: { id: 'o1', name: 'Camper' }, variant: null }, event: { id: 'e1', title: 'Youth Camp' }, user: { id: 'u1', name: 'Thandi' }, attendee: { name: 'Lulu' } } });
|
||||
const res = await request(app).get('/api/v2/checkin/QR1');
|
||||
expect(res.body.data.ticket).toMatchObject({ attendee: 'Lulu', bookedBy: 'Thandi', ticketOptionId: 'o1', remaining: 2, consentStatus: 'not_required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 deprecation headers', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { successorOf } = require('../src/v2/deprecation');
|
||||
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
|
||||
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
|
||||
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1/full');
|
||||
expect(successorOf('PUT', '/api/events/options/o1')).toBe('/api/v2/ticket-options/o1');
|
||||
expect(successorOf('DELETE', '/api/events/e1/attachments/a1')).toBe('/api/v2/events/e1/attachments/a1');
|
||||
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
|
||||
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
|
||||
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
|
||||
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
|
||||
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
|
||||
expect(successorOf('GET', '/api/v2/events')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared middleware on v2', () => {
|
||||
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { legacyErrorsToV2 } = require('../src/v2/errors');
|
||||
const mini = express();
|
||||
mini.use('/api/v2', legacyErrorsToV2);
|
||||
mini.get('/api/v2/limited', (_req, res) => { res.status(429).json({ message: 'Too many requests, please try again later.' }); });
|
||||
mini.post('/api/v2/locked', (_req, res) => { res.status(403).json({ code: 'LICENCE_READ_ONLY', message: 'Read only.' }); });
|
||||
mini.get('/api/v2/twofa', (_req, res) => { res.status(401).json({ code: 'TWO_FACTOR_REQUIRED', message: 'Set up 2FA.' }); });
|
||||
mini.get('/api/v2/fine', (_req, res) => { res.json({ data: { message: 'not an error' } }); });
|
||||
expect((await request(mini).get('/api/v2/limited')).body).toEqual({ error: { code: 'RATE_LIMITED', message: 'Too many requests, please try again later.' } });
|
||||
expect((await request(mini).post('/api/v2/locked')).body).toEqual({ error: { code: 'READ_ONLY', message: 'Read only.', reason: 'LICENCE_READ_ONLY' } });
|
||||
expect((await request(mini).get('/api/v2/twofa')).body.error).toMatchObject({ code: 'UNAUTHENTICATED', reason: 'TWO_FACTOR_REQUIRED' });
|
||||
expect((await request(mini).get('/api/v2/fine')).body).toEqual({ data: { message: 'not an error' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('events (staff)', () => {
|
||||
const stored = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'e1', title: 'Camp', description: null, startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
|
||||
isActive: true, isHidden: true, requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open',
|
||||
campusId: null, seriesId: null, picture: '/uploads/events/c.jpg', waitlistEnabled: true,
|
||||
eventOptions: [{ id: 'o1', name: 'Main Ticket', price: 600, isMainTicket: true, stockLimit: 0, earlyBirdTiers: [] }], sessions: [], ...over,
|
||||
});
|
||||
afterEach(() => { mockScope.blockEvent = false; });
|
||||
|
||||
test('/events/manage lists hidden events for staff, with the rest of each record passed through', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockEvents.listManagedEvents.mockResolvedValue([stored()]);
|
||||
const res = await request(app).get('/api/v2/events/manage?past=true');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data[0]).toMatchObject({ id: 'e1', isHidden: true, waitlistEnabled: true, startDate: '2026-12-04T16:00:00.000Z' });
|
||||
expect(res.body.data[0].picture).toMatch(/^http:\/\/.*\/uploads\/events\/c\.jpg$/);
|
||||
expect(mockEvents.listManagedEvents).toHaveBeenCalledWith({ includePast: true, includeInactive: false, excludeClosed: false, campus: undefined }, expect.objectContaining({ scope: null }));
|
||||
});
|
||||
|
||||
test('members can\'t list or create', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/events/manage')).body.error.code).toBe('FORBIDDEN');
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
expect((await request(app).post('/api/v2/events').send({ title: 'X', startDate: '2026-12-01', endDate: '2026-12-02' })).body.error.code).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
test('create needs a title and dates; update passes only what was sent', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
const bad = await request(app).post('/api/v2/events').send({ title: '' });
|
||||
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['endDate', 'startDate', 'title']);
|
||||
mockEvents.createEvent.mockResolvedValue(stored());
|
||||
const made = await request(app).post('/api/v2/events').send({ title: 'Camp', startDate: '2026-12-04T16:00', endDate: '2026-12-06T12:00', waitlistEnabled: true });
|
||||
expect(made.status).toBe(201);
|
||||
expect(mockEvents.createEvent.mock.calls[0][0]).toMatchObject({ title: 'Camp', waitlistEnabled: true });
|
||||
|
||||
mockEvents.updateEvent.mockResolvedValue({ ...stored({ title: 'New' }), sessionChanges: null });
|
||||
const upd = await request(app).patch('/api/v2/events/e1').send({ title: 'New' });
|
||||
expect(upd.body.data).toMatchObject({ title: 'New', sessionChanges: null });
|
||||
expect(mockEvents.updateEvent).toHaveBeenCalledWith('e1', { title: 'New' }, { id: 'v1', role: 'supervisor' }, expect.any(Object));
|
||||
});
|
||||
|
||||
test("a campus-scoped supervisor can't change another campus's event", async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockScope.blockEvent = true;
|
||||
expect((await request(app).patch('/api/v2/events/e1').send({ title: 'X' })).status).toBe(404);
|
||||
expect(mockEvents.updateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('deactivating is for admins', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).delete('/api/v2/events/e1')).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
mockEvents.deactivateEvent.mockResolvedValue({ id: 'e1', isActive: false });
|
||||
expect((await request(app).delete('/api/v2/events/e1')).body.data).toEqual({ id: 'e1', isActive: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('one event in full, options, attachments', () => {
|
||||
const detail = {
|
||||
id: 'e1', title: 'Camp', description: null, startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z', isActive: true, isHidden: false,
|
||||
requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open', campusId: null, seriesId: null, picture: null,
|
||||
eventOptions: [], sessions: [], form: null, attachments: [], bundles: [],
|
||||
};
|
||||
afterEach(() => { mockUpload.file = null; mockUpload.error = null; });
|
||||
|
||||
test('/events/{id}/full asks for the staff view only for staff', async () => {
|
||||
mockDetails.getEventDetail.mockResolvedValue(detail);
|
||||
await request(app).get('/api/v2/events/e1/full');
|
||||
expect(mockDetails.getEventDetail.mock.calls[0][1]).toEqual({ staff: false, scope: null });
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
const res = await request(app).get('/api/v2/events/e1/full');
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDetails.getEventDetail.mock.calls[1][1]).toEqual({ staff: true, scope: null });
|
||||
});
|
||||
|
||||
test('the calendar file is a download, not JSON', async () => {
|
||||
mockDetails.eventCalendar.mockResolvedValue({ filename: 'Camp.ics', ics: 'BEGIN:VCALENDAR\nEND:VCALENDAR' });
|
||||
const res = await request(app).get('/api/v2/events/e1/calendar.ics');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/calendar');
|
||||
expect(res.headers['content-disposition']).toBe('attachment; filename="Camp.ics"');
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
test('ticket options: supervisors add and change them, only admins remove them', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockDetails.createOption.mockResolvedValue({ id: 'o2', eventId: 'e1', name: 'Child', price: 300, isMainTicket: false, stockLimit: 0, staffPrice: null, sessionMode: 'all' });
|
||||
const made = await request(app).post('/api/v2/events/e1/ticket-options').send({ name: 'Child', price: 300 });
|
||||
expect(made.status).toBe(201);
|
||||
expect((await request(app).post('/api/v2/events/e1/ticket-options').send({ price: 300 })).body.error.fields).toHaveProperty('name');
|
||||
expect((await request(app).delete('/api/v2/ticket-options/o2')).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
mockDetails.deleteOption.mockRejectedValueOnce(new ServiceError(400, 'Cannot delete event option that has registrations'));
|
||||
expect((await request(app).delete('/api/v2/ticket-options/o2')).body.error.message).toBe('Cannot delete event option that has registrations');
|
||||
});
|
||||
|
||||
test('uploading an attachment: the file, or why it was refused', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockUpload.error = new Error('Unsupported file type');
|
||||
const refused = await request(app).post('/api/v2/events/e1/attachments');
|
||||
expect(refused.body.error).toMatchObject({ code: 'VALIDATION_FAILED', fields: { file: 'Unsupported file type' } });
|
||||
mockUpload.error = null;
|
||||
mockUpload.file = { originalname: 'packing.pdf', filename: 'event-file-1.pdf', mimetype: 'application/pdf', size: 1200 };
|
||||
mockDetails.recordAttachment.mockResolvedValue({ id: 'a1', eventId: 'e1', originalName: 'packing.pdf', filename: 'event-file-1.pdf', mimeType: 'application/pdf', size: 1200, url: 'http://x/uploads/event-files/event-file-1.pdf', createdAt: new Date('2026-10-29T10:00:00Z') });
|
||||
const ok = await request(app).post('/api/v2/events/e1/attachments');
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body.data).toMatchObject({ originalName: 'packing.pdf', createdAt: '2026-10-29T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
test('the upload is described as multipart and the calendar as a file', () => {
|
||||
const doc = buildOpenApi({ version: 'test' }) as { paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> }; responses: Record<string, { content: Record<string, unknown> }> }>> };
|
||||
expect(Object.keys(doc.paths['/events/{id}/attachments'].post.requestBody!.content)).toEqual(['multipart/form-data']);
|
||||
expect(Object.keys(doc.paths['/events/{id}/calendar.ics'].get.responses['200'].content)).toEqual(['text/calendar']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sections, costs, image uploads', () => {
|
||||
afterEach(() => { mockImage.file = null; });
|
||||
|
||||
test('costs: supervisors read them, admins add them; per-item needs a ticket type', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockSetup.listCosts.mockResolvedValue([{ id: 'c1', eventId: 'e1', label: 'Hall', costType: 'once_off', amount: 1500, eventOptionId: null, eventOption: null, paidFromMethod: null, notes: null, createdAt: new Date('2026-10-01T10:00:00Z') }]);
|
||||
expect((await request(app).get('/api/v2/events/e1/costs')).body.data[0]).toMatchObject({ label: 'Hall', createdAt: '2026-10-01T10:00:00.000Z' });
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'once_off', amount: 1500 })).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'monthly', amount: 1 })).body.error.fields).toHaveProperty('costType');
|
||||
mockSetup.createCost.mockRejectedValueOnce(new ServiceError(400, 'eventOptionId is required for per-item costs'));
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Shirt', costType: 'per_item', amount: 80 })).body.error.message).toBe('eventOptionId is required for per-item costs');
|
||||
});
|
||||
|
||||
test('sections need staff to list and a supervisor to add', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/sections')).status).toBe(403);
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockSetup.listSections.mockResolvedValue([]);
|
||||
expect((await request(app).get('/api/v2/sections?eventId=e1')).body).toEqual({ data: [] });
|
||||
expect(mockSetup.listSections).toHaveBeenCalledWith('e1', expect.anything());
|
||||
});
|
||||
|
||||
test('an event picture comes back as a path to save and a full address', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).post('/api/v2/uploads/event-image')).body.error.fields).toEqual({ image: 'Choose an image' });
|
||||
mockImage.file = { filename: 'event-123.jpg' };
|
||||
const res = await request(app).post('/api/v2/uploads/event-image');
|
||||
expect(res.body.data.path).toBe('/uploads/events/event-123.jpg');
|
||||
expect(res.body.data.url).toMatch(/^http:\/\/.*\/uploads\/events\/event-123\.jpg$/);
|
||||
});
|
||||
|
||||
test('branding images are for admins once accounts exist', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockImage.file = { filename: 'logo.png' };
|
||||
expect((await request(app).post('/api/v2/uploads/logo')).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,12 @@ const mockDb = {
|
||||
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
payment: { findUnique: jest.fn() },
|
||||
ticketUsage: { findUnique: jest.fn() },
|
||||
event: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const webhooks = require('../src/utils/webhooks');
|
||||
@@ -112,4 +116,25 @@ describe('outbound webhooks', () => {
|
||||
expect(made[0].eventId).toBe(made[1].eventId);
|
||||
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
|
||||
});
|
||||
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
|
||||
test('each event type has an example shaped like the real payload', async () => {
|
||||
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
|
||||
: Array.isArray(v) ? v.map(shape) : '·');
|
||||
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
|
||||
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
|
||||
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
|
||||
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
|
||||
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
|
||||
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
|
||||
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
|
||||
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
|
||||
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
|
||||
for (const t of webhooks.EVENT_TYPES) {
|
||||
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
|
||||
mockDb.registration.findUnique.mockResolvedValue(reg(status));
|
||||
const built = await webhooks.buildPayload(t.type, 'x');
|
||||
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user