Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992fad37d2 | ||
|
|
a4bdf846ad | ||
|
|
8cc2cefe80 | ||
|
|
9913a13f70 | ||
|
|
89a952dedb | ||
|
|
61ce138447 | ||
|
|
b28c8a8876 | ||
|
|
8a424ac77e | ||
|
|
31aa787b17 | ||
|
|
51992814d2 | ||
|
|
a0ff8dfc89 | ||
|
|
88d9276960 | ||
|
|
1ae4c47001 | ||
|
|
3693d5f2c8 | ||
|
|
62b10c3db5 | ||
|
|
2b3b0e94c9 | ||
|
|
83e3aee40c | ||
|
|
f77b4f4164 | ||
|
|
36cabfee0d | ||
|
|
206a814973 | ||
|
|
311fe8b6f1 | ||
|
|
7ce25bf40b | ||
|
|
376b93a438 | ||
|
|
0cd78ab1b7 | ||
|
|
d49aebb1b0 | ||
|
|
98e5d05548 | ||
|
|
14ece27690 | ||
|
|
5b50b0ce4f | ||
|
|
99f16375b9 | ||
|
|
a62cac6335 | ||
|
|
c4fff7c835 | ||
|
|
e310743b92 | ||
|
|
b6c13de5dc | ||
|
|
2a5b12db20 | ||
|
|
8747431bd5 | ||
|
|
e95d52ccdd | ||
|
|
fdc3db8d23 | ||
|
|
1a25896999 | ||
|
|
97c368c898 | ||
|
|
94e4de8794 | ||
|
|
3a5b042525 | ||
|
|
a7231aad0c | ||
|
|
254a5daebd | ||
|
|
28e31ffb99 | ||
|
|
6ee1438e23 | ||
|
|
a45417948d | ||
|
|
31ee2a9aee | ||
|
|
da67f80d6a | ||
|
|
4b7fb19f37 | ||
|
|
0fc4f83c34 | ||
|
|
59fde801f7 | ||
|
|
733b23955b | ||
|
|
d6082693c2 | ||
|
|
16357e6006 | ||
|
|
9547793f25 | ||
|
|
99efe7a7d0 | ||
|
|
3c983070a1 | ||
|
|
c63c3a3604 | ||
|
|
67d0a83ab8 | ||
|
|
2bcc9ac651 | ||
|
|
b2906011fd | ||
|
|
70850df4f6 | ||
|
|
cbebcdbd48 | ||
|
|
849998acb3 | ||
|
|
c5fe17a1ef | ||
|
|
6164fdcb3b | ||
|
|
1510c2a057 | ||
|
|
fa4191d18c | ||
|
|
e0dae5647f | ||
|
|
4109cb4258 | ||
|
|
ce9734090c | ||
|
|
9012ec64b6 |
+531
@@ -7,6 +7,537 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] - 2026-11-02
|
||||
|
||||
### Added
|
||||
|
||||
- **AI helpers — the plumbing** (2.0, roadmap §15). The site calls AI through the Cross Events Hub
|
||||
(protocol §3b, Hub 1.6.0), which holds the Claude API key and a monthly allowance in rand per
|
||||
licence; no key is ever stored on the site. `GET /api/v2/ai/status` says whether to show the AI
|
||||
buttons: hidden when the licence has no AI (worked out from the licence token, without asking the
|
||||
Hub), disabled with the reason once the month's allowance is used up or the Hub can't be reached.
|
||||
The answer is cached for a minute and refreshed by every AI call. `<AiButton>` and
|
||||
`useAiStatus()` on the frontend; only supervisors and admins see AI.
|
||||
- **Site settings → AI** (admins): this month's use against the allowance, use per person, per
|
||||
helper and per day, a switch per helper, and an optional most-uses-per-person-per-day. Admins are
|
||||
emailed once a month when 80% is used. Changes are audited (`ai_settings_changed`).
|
||||
- Each AI call is logged in `AiCall` (person, helper, outcome, cost — never the prompt or answer);
|
||||
anonymising an account unlinks its calls, and calls go after 13 months. Anthropic is added to the
|
||||
operator register, switched off until a site uses AI. Migration `20261030120000_ai_helpers`.
|
||||
- v2 errors have a new code, `UNAVAILABLE` (503), for when the Hub or the AI service can't be
|
||||
reached.
|
||||
- **The five AI helpers' API** (supervisors and admins): `POST /api/v2/ai/draft-event` (the event
|
||||
form from a description, with ticket options, early bird, schedule and a budget from the notes),
|
||||
`/ai/summarise-report` (reports without people in them only), `/ai/draft-message` (email or
|
||||
WhatsApp, with `{{name}}` and the event placeholders), `/ai/summarise-feedback` (an event's
|
||||
feedback, sent without who answered; recorded in the access log) and `/ai/improve-text`
|
||||
(improve, shorter, or only fix mistakes). Every helper is told today's date. Email addresses,
|
||||
phone numbers and ID numbers in what's sent are swapped for placeholders and put back in the
|
||||
answer, so they never leave the site. Two checks are worked out in code as well as by the AI: a
|
||||
weekday that doesn't match its date, and costs a full event can't cover.
|
||||
- **AI buttons in the app** (only where the licence has AI; supervisors and admins):
|
||||
- New event → **Describe your event**: a few sentences fill in the form (details, dates, tickets,
|
||||
early bird; course dates as sessions), with the checks and a budget from the notes to look at
|
||||
first. Nothing changes until "Fill in the form".
|
||||
- **Improve / Shorter / Fix mistakes** under the event description, the site banner, and the
|
||||
email and WhatsApp message boxes; the suggestion replaces the text only with "Use this".
|
||||
- **Draft with AI** in Email attendees and WhatsApp attendees: a short note becomes the email's
|
||||
subject and body, or the WhatsApp message, with `{{name}}` and the event's details.
|
||||
- **Summarise this report** on reports without people in them, and **Summarise feedback** per
|
||||
event on the Feedback summary report.
|
||||
- Every suggestion says "AI suggestion — check it before saving".
|
||||
- **Privacy notice:** the built-in text says how the AI helpers use information and where requests
|
||||
go (sections 4 and 9). Sites with their own published notice see the change in the notice
|
||||
editor.
|
||||
- `npm run ai:eval` (backend): the helpers' eval set — fixed examples run through the site's Hub
|
||||
connection, with automatic checks on the facts and every answer printed to read.
|
||||
- **MIGRATING-TO-2.md:** what changes for integrations and for whoever runs the server, with every
|
||||
v1 address and its v2 replacement.
|
||||
|
||||
### Removed
|
||||
|
||||
- **API v1** (`/api/…`). Every screen has used `/api/v2` since 1.51.16. Any v1 address now answers
|
||||
`410 Gone` with `code: "API_V1_REMOVED"`, except what outside senders still use: the provider
|
||||
webhooks (`/api/webhooks/yoco|payfast|whatsapp` and WAWP's oldest `/api/whatsapp/webhook`) and
|
||||
the links in messages already sent (calendar files and one-click unsubscribe answer `308` with
|
||||
their v2 address; a mail app's one-click POST to the old address is still handled) —
|
||||
`legacyV1Router` and `v1Removed` in `src/v2/links.ts`. The 43 v1 route files are gone (the
|
||||
controllers stay: `v1Route` runs them behind v2 routes); so are the `Deprecation` headers
|
||||
(`src/v2/deprecation.ts`), the v1 reference at `/docs` (it redirects to `/docs/v2`) and the
|
||||
hand-written `backend/API_DOCUMENTATION.md` (now a pointer to `/docs/v2`).
|
||||
- `node-fetch` from the backend's dependencies (nothing used it since the move to native `fetch`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Passwords are hashed with Argon2id** (`@node-rs/argon2`, 19 MiB, 2 passes) instead of bcrypt.
|
||||
bcrypt hashes still sign in, and are re-hashed with Argon2id the first time each person signs in
|
||||
(`needsRehash` in `src/config/auth.js`, used by `logIn`). `bcryptjs` stays only to check those.
|
||||
- The campus-scope route test runs over the v2 routes (`tests/v2CampusScope.test.ts`): each staff
|
||||
or supervisor route needs a campus-scope middleware or must be on the list of routes whose
|
||||
service checks campuses itself. The read-only and impersonation lists name v2 paths only.
|
||||
- e2e tests call `/api/v2` only (`v2()` in `e2e/fixtures.ts` returns the answer's `data`);
|
||||
Playwright waits for `/api/v2/setup/needed`.
|
||||
- Documented shapes corrected: the access log's `actions` (action → label) and a cash-up's
|
||||
`cashDenominations` (notes and coins).
|
||||
- **The React Compiler lint rules are errors again** (`set-state-in-effect`, `purity`,
|
||||
`immutability`, `refs`, `static-components`); the 192 places they flagged are fixed, and
|
||||
`npx eslint src` reports nothing. New hooks in `frontend/src/lib/hooks.ts`: `useStateSync` (state
|
||||
that follows other state, set while rendering), `useExternalEffect` (loading from the API or
|
||||
reading the browser), `useNow` (the time, refreshed each minute); `hardNavigate` in
|
||||
`lib/navigation.ts` for the full reloads after signing out or switching who is signed in. Other
|
||||
pages navigate with the router. Several resets moved into the handlers that cause them.
|
||||
- Small behaviour fixes from that work: the site banner now appears and goes at its set times
|
||||
without a reload; lists of past/upcoming events stay right on a page left open; registering
|
||||
goes to the next page without a full reload; popups keep the latest close handler without
|
||||
writing it during render.
|
||||
|
||||
## [1.52.3] - 2026-11-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Licence read-only mode works again.** The read-only guard (`src/hub/readOnly.js`) only listed
|
||||
v1 paths, and every screen has used `/api/v2` since 1.51.15 — so a suspended, revoked or
|
||||
unlicensed site could still create and change events, take registrations and start payments.
|
||||
The v2 successors of every blocked v1 route are now blocked too (answered as `READ_ONLY` with
|
||||
reason `LICENCE_READ_ONLY`). A test checks each blocked v2 path is a real route in the OpenAPI
|
||||
document, so a renamed route can't slip out of the list.
|
||||
|
||||
## [1.52.2] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- **`scripts/deploy.sh` stops before touching anything when files in the install folder were
|
||||
changed or added by hand**, and lists them. A setup wizard run on one server had added files to
|
||||
`backend/src` that the backend build picked up, so the deploy failed halfway and rolled back.
|
||||
Ignored files (`.env`, uploads, backups, `ecosystem.config.js`) never count.
|
||||
`ALLOW_LOCAL_CHANGES=1` deploys anyway.
|
||||
- `bash scripts/deploy.sh --check` runs only that check (no backup, fetch, build or reload). The
|
||||
script can also be run from a copy outside the install (e.g. `/tmp/deploy.sh`, from the folder
|
||||
you're in), so an older install can check before updating — see DEPLOYMENT.md, "Updating".
|
||||
|
||||
## [1.52.1] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- **The API documentation (`/docs/v2`, `GET /api/v2/openapi.json`) now describes every route in
|
||||
full.** Before, most routes answered by a v1 handler showed an empty body and `data: unknown`.
|
||||
Each one now documents the body it reads and what it answers, from the handler's actual code, and
|
||||
every path and query parameter has a description. Actions that take no input say "No body
|
||||
needed".
|
||||
- How: `documented: { body?, response? }` on `v1Route` (and `defineRoute`), with shared shapes in
|
||||
`src/v2/docs.ts`. These are documentation only — requests and answers aren't checked against
|
||||
them, so nothing an app already does can start failing — but outside production a body or
|
||||
answer that doesn't fit is logged (`[api/v2] … doesn't match its documented schema`).
|
||||
`tests/v2Docs.test.ts` keeps the document complete.
|
||||
- The funnel report's `eventIds` and the webhook deliveries' `status`/`before` filters are now
|
||||
declared (they worked, but weren't in the document or the typed frontend calls).
|
||||
- The frontend's generated API types are richer; a few calls had their types tightened to match
|
||||
(ticket transfer, breach register, kids check-out). Nothing changes in what they send.
|
||||
|
||||
## [1.52.0] - 2026-11-02 — urgent
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Paid door and manual registrations no longer end up without tickets.** On an event with a
|
||||
required attendee form, tickets wait until a form is in for each person — but At the door and
|
||||
Manual registration never asked for the form, so payment was recorded and nobody could be checked
|
||||
in. The form now pops up when someone is registered, selected or pays at the door (and after
|
||||
registering or paying in Manual registration): fill it in, or **Fill in later**, which issues the
|
||||
tickets once it's paid and sends the payer a link to the form (`POST
|
||||
/api/v2/registrations/{id}/form-later`, staff; `Registration.formWaivedAt`). Closing the popup
|
||||
counts as Fill in later. The door's check-in panel says when a form is holding tickets back.
|
||||
- **Picked-session bookings needed a form per session.** A person booked on three sessions needed
|
||||
three forms before their tickets were issued; each person now needs one (`formPlaces` in
|
||||
`utils/ticketUtils.js`).
|
||||
- **A required form with only statements and headings held tickets back for ever** (nothing to
|
||||
answer). Only a required form with a question counts now (`formNeedsAnswers`). Every place that
|
||||
decided this on its own (coupon making it free, manual and online registration) now asks the
|
||||
ticket generator (`formsOutstanding`).
|
||||
- **Self-service kiosk:** it asked for one form per main ticket picked, so a visitor adding to an
|
||||
existing registration (or booking picked sessions) could finish with the forms still short and
|
||||
no tickets. It now asks for the forms the server says are missing (`formsNeeded` on `POST
|
||||
/api/v2/registrations/manual`). Headings on the form showed as an empty text box; they're shown
|
||||
as text now, with the statements.
|
||||
- Recording a payment answers `formsNeeded` when a form still holds the tickets back.
|
||||
|
||||
### Added
|
||||
|
||||
- **Corrections** (admins, `/dashboard/admin/corrections`): every registration at an event —
|
||||
past and closed ones too — with what needs putting right. Issue all missing tickets at once
|
||||
(forms left for later); fill in a form; issue tickets before payment ("pay later", the balance
|
||||
is still owed — `Registration.ticketsReleasedAt`); make it free (the balance is written off as a
|
||||
complimentary discount, `Registration.complimentaryDiscount`, part of `discountTotal`, shown as
|
||||
"Complimentary" on invoices and receipts; not on closed events); and check a ticket in or undo it
|
||||
after the event, for any session (a manual check-in, no message sent). Sending the tickets is a
|
||||
choice. `/api/v2/corrections/…` (admin): `GET events/{id}`, `POST events/{id}/issue-missing`,
|
||||
`POST registrations/{id}/tickets`, `POST registrations/{id}/complimentary`,
|
||||
`POST tickets/{id}/check-in`. Code in `src/services/corrections.ts`.
|
||||
- Audit actions `attendee_form_waived`, `tickets_issued_override`, `registration_complimentary`
|
||||
and `checkin_corrected`. Migration `20261102120000_corrections`.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Update before your next event with a required attendee form. After updating, open Admin →
|
||||
Corrections for a past event to issue any tickets that went missing (leave "Send them" off if
|
||||
the event is over).
|
||||
|
||||
## [1.51.17] - 2026-10-30 — security release
|
||||
|
||||
### Security
|
||||
|
||||
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
|
||||
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
|
||||
the same answer and status for every address, sent before any work that only happens for a
|
||||
real account (so the response time doesn't tell either); the reset link is made and sent
|
||||
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
|
||||
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
|
||||
different status on v1); it now gets exactly the answer a wrong password gets, after a password
|
||||
check against a stand-in hash so it takes as long.
|
||||
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
|
||||
reset emails.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
|
||||
|
||||
## [1.51.16] - 2026-10-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
|
||||
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
|
||||
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
|
||||
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
|
||||
a redirect on a POST; they now name their v2 address in a `Link` header.
|
||||
- **Links in sent messages on v2:** "Add to calendar" links point at
|
||||
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
|
||||
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
|
||||
with their v2 address; a mail app's one-click POST to the old address is still handled there.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
|
||||
handler like `/api/whatsapp/webhook`.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
|
||||
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
|
||||
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
|
||||
|
||||
## [1.51.15] - 2026-10-30
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
|
||||
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
|
||||
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
|
||||
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
|
||||
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
|
||||
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
|
||||
(`/payments/yoco/…`, `/payments/payfast/…`).
|
||||
- `v1Route` handles `res.download` and `res.sendFile`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
|
||||
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
|
||||
- Ending a viewing-as session works on v2 while the session is read-only.
|
||||
|
||||
## [1.51.14] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
|
||||
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
|
||||
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
|
||||
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
|
||||
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
|
||||
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
|
||||
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
|
||||
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
|
||||
- `defineRoute` refuses a method and path defined twice (the first would silently win).
|
||||
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
|
||||
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
|
||||
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
|
||||
reports and the public event lists use v2.
|
||||
|
||||
## [1.51.13] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
|
||||
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
|
||||
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
|
||||
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
|
||||
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
|
||||
|
||||
### Changed
|
||||
|
||||
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
|
||||
|
||||
## [1.51.12] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — tickets and attendance:** `/me/tickets` (+ `/email`), `/tickets` (the staff list, one
|
||||
ticket, sending, issuing, scan stats and recent scans, the ticket for a QR code), transfers
|
||||
(`/tickets/{id}/transfer`, `/ticket-transfers/{token}` to accept or decline), `/events/{id}/tickets`;
|
||||
offline scanning (`/scan-locks`: take tickets offline, snapshot, sync, release, reports); the
|
||||
attendance register (`/events/{id}/attendance`, its PDF/Excel export) and certificates (issue,
|
||||
send, revoke, download, preview, verify, `/me/certificates`); allocations (`/events/{id}/allocations`,
|
||||
`/allocation-sets`, `/allocation-groups`, `/me/allocation-groups`).
|
||||
- Frontend: `apiV2Raw()` for screens whose helpers pass the path as a string; file downloads
|
||||
read v2's error shape.
|
||||
- `v1Route` can run a v1 router (with `map: () => ({ url })`); a chain that answers nothing is a
|
||||
404 instead of waiting.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tickets, transfers, offline scanning, attendance, certificates, allocations and the kids room
|
||||
set-up use v2.
|
||||
|
||||
## [1.51.11] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
|
||||
assigned to registrations, stats, providers and their settings, open checkouts), payment links
|
||||
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
|
||||
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
|
||||
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
|
||||
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
|
||||
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
|
||||
feedback (set-up, send now, the summary, the form by link).
|
||||
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
|
||||
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
|
||||
v1's `code` as `reason`; piped files become downloads.
|
||||
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
|
||||
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
|
||||
and donate pages, the user dashboard) uses v2.
|
||||
|
||||
## [1.51.10] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
|
||||
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
|
||||
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
|
||||
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
|
||||
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
|
||||
attendees and their form links, attendee form drafts and responses
|
||||
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
|
||||
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
|
||||
`registrationController.js` (now about 60 lines of v1 wrappers).
|
||||
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
|
||||
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
|
||||
door, manual registration, self-service, payments, forms, the attendee composers, reports and
|
||||
the user dashboard all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
|
||||
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
|
||||
and links are now 1.51.16.
|
||||
|
||||
## [1.51.9] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
|
||||
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
|
||||
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
|
||||
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
|
||||
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
|
||||
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
|
||||
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
|
||||
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
|
||||
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
|
||||
bundle, coupon and campus controllers are now thin v1 wrappers.
|
||||
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
|
||||
middleware's codes.
|
||||
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
|
||||
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
|
||||
coupon field and bundle quotes on the register page all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
|
||||
coupon field shows that reason as before.
|
||||
|
||||
## [1.51.8] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
+37
-1
@@ -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.
|
||||
@@ -311,6 +311,42 @@ and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
|
||||
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
|
||||
or checks anything out: they would be built into the release, or block the checkout. Move added
|
||||
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
|
||||
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
|
||||
|
||||
```bash
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh --check
|
||||
```
|
||||
|
||||
An install still on an older release can run the new check without updating anything — fetch the
|
||||
script on its own and run it from the install folder:
|
||||
|
||||
```bash
|
||||
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
|
||||
bash /tmp/deploy.sh --check
|
||||
```
|
||||
|
||||
### Upgrading Node
|
||||
|
||||
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
|
||||
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 |
|
||||
|
||||
+23
-5
@@ -327,6 +327,12 @@ Feedback → Attendance**.
|
||||
Date, plus Statement and Heading blocks; required/optional, help text,
|
||||
reordering. The whole form can be marked required.
|
||||
- **Drafts** — partially filled forms are auto-saved.
|
||||
- **Required forms and tickets** — a required form (with at least one question)
|
||||
holds a registration's tickets back until a form is in for each person (a
|
||||
picked-session booking counts each person once). At the door and in Manual
|
||||
registration the form pops up to fill in with the attendee; **Fill in later**
|
||||
(any staff) issues the tickets anyway once it's paid and sends the payer a link
|
||||
to the form. The kiosk asks the visitor for exactly the forms still missing.
|
||||
- **Responses** — view, filter (event, user, past/inactive) and print one
|
||||
response per A4 page. Supervisors can edit responses.
|
||||
|
||||
@@ -492,6 +498,14 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
current filters, apply one with a click, star one as the default for the page
|
||||
(not applied over a deep link). Per person, on the server.
|
||||
- **Cashup** — see below.
|
||||
- **Corrections** (`/dashboard/admin/corrections`, admins only) — for any event,
|
||||
past and closed ones too: registrations missing tickets, with forms to fill in,
|
||||
or owing money. **Issue all missing tickets** at once (forms left for later),
|
||||
fill in a form, **issue tickets — pay later** (balance still owed), **make it
|
||||
free** (the balance written off as a complimentary discount, so income stays
|
||||
what was paid; not on closed events), and **check people in (or undo) after the
|
||||
event** for any session. Optionally send the tickets. Every correction is in
|
||||
the audit log with the reason given.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, settings changes, and coupon codes
|
||||
@@ -889,12 +903,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`); **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); the
|
||||
frontend's v2 calls are typed from the OpenAPI document.
|
||||
- Backend status page (`/`); **API v2** (`/api/v2`, 1.51.0 — the only API since 2.0, which removed
|
||||
v1; `/docs` redirects to `/docs/v2`) 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
|
||||
@@ -934,4 +952,4 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# Moving to Cross Events 2.0
|
||||
|
||||
Cross Events 2.0 removes version 1 of the API (`/api/…`). Everything the app does has been on
|
||||
version 2 (`/api/v2/…`) since 1.51.16, so **a church that only uses the app has nothing to do** —
|
||||
the update looks the same as any other. This guide is for anyone with their own integration (a
|
||||
script, a website plug-in, a spreadsheet import) that calls the API directly, and for whoever runs
|
||||
the server.
|
||||
|
||||
## What still works without changes
|
||||
|
||||
- **Payment and WhatsApp webhooks.** Yoco, PayFast and WAWP keep posting to the addresses they were
|
||||
registered with: `/api/webhooks/yoco`, `/api/webhooks/payfast`, `/api/webhooks/whatsapp` and the
|
||||
oldest WAWP address, `/api/whatsapp/webhook`. New registrations use `/api/v2/webhooks/…`.
|
||||
- **Links in messages already sent.** "Add to calendar" links (`/api/events/{id}/ics`) and one-click
|
||||
unsubscribe links (`/api/privacy/unsubscribe/{token}/one-click`) answer `308` with their v2
|
||||
address; a mail app's one-click `POST` to the old address is still handled.
|
||||
- **Outbound webhooks** (Site settings → Webhooks) send the same events and payloads as before.
|
||||
- **Pages people open** — event pages, consent forms, ticket transfers, waitlist offers — are app
|
||||
pages, not API addresses, so they were never affected.
|
||||
|
||||
Any other `/api/…` address now answers **`410 Gone`**:
|
||||
|
||||
```json
|
||||
{ "code": "API_V1_REMOVED", "message": "API v1 was removed in Cross Events 2.0. Use /api/v2 — it is described at /docs/v2." }
|
||||
```
|
||||
|
||||
`/docs` (the v1 reference) redirects to `/docs/v2`.
|
||||
|
||||
## How v2 differs
|
||||
|
||||
The full reference is at `/docs/v2` on your site (sign in as an admin), generated from the code so
|
||||
it is always current. The OpenAPI 3.1 document is `GET /api/v2/openapi.json`.
|
||||
|
||||
| | v1 | v2 |
|
||||
|---|---|---|
|
||||
| Answers | whatever each endpoint returned | always `{ "data": … }` |
|
||||
| Lists | plain arrays, some paged | `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, `?page=` and `?limit=` (most lists up to 100) |
|
||||
| Errors | `{ "message", "code"? }`, often `400` | `{ "error": { "code", "message", "reason"?, "fields"? } }` — `code` is one of a fixed set (`VALIDATION_FAILED`, `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `READ_ONLY`, `RATE_LIMITED`, `UNAVAILABLE`, `INTERNAL`); v1's `code` is now `reason` |
|
||||
| Input | anything | checked against the documented schema; mistakes are `VALIDATION_FAILED` with `fields` |
|
||||
| Changing a record | mostly `PUT` | `PATCH` for partial changes (events, ticket options, variants, series, users, coupons…) |
|
||||
| Dates | mixed | ISO 8601, UTC |
|
||||
|
||||
Things that behave differently, not just live at a new address:
|
||||
|
||||
- **Signing in:** `POST /api/v2/auth/login` takes `{ identifier, password }` (an email or phone
|
||||
number). If two-step sign-in is on, the answer is `status: "code_sent"` with a `challengeId`;
|
||||
finish with `POST /api/v2/auth/login/code`.
|
||||
- **Signing up:** `POST /api/v2/auth/signup` needs `acceptTerms: true`; the phone number is
|
||||
`phone`. The answer's `data` holds `token` and `user`.
|
||||
- **Checking a ticket in:** `POST /api/v2/checkin/{qrCode}` with `{ people? }` (was `qty`). Every
|
||||
normal result is a `200` with an `outcome` — `checked_in`, `already_used`, `consent_required`,
|
||||
`wrong_event`, … — where v1 used `403`s. Look a ticket up first with `GET /api/v2/checkin/{qrCode}`.
|
||||
- **Paying online:** `POST /api/v2/registrations/{id}/checkout` (the registration is in the path,
|
||||
not the body).
|
||||
- **Cancelling a registration:** `POST /api/v2/registrations/{id}/cancel` (was `DELETE`).
|
||||
- **Staff lists of registrations** are paged (up to 200 a page).
|
||||
- **A suspended or unlicensed site** refuses new events, registrations and payments with
|
||||
`READ_ONLY` / reason `LICENCE_READ_ONLY`.
|
||||
|
||||
## For whoever runs the server
|
||||
|
||||
1. Deploy as usual: `bash scripts/deploy.sh v2.0.0` (it backs up, migrates, builds and rolls back
|
||||
on failure). Run `bash scripts/deploy.sh --check` first if anyone may have changed files by hand.
|
||||
2. Two migrations run: `20261030120000_ai_helpers` (the AI call log) and anything newer listed in the
|
||||
CHANGELOG.
|
||||
3. **AI helpers** need the site connected to the Hub (Site settings → Licence) and a monthly AI cap
|
||||
above R0 on the licence. The Hub must run 1.6.0 or later. With no cap the AI buttons don't show
|
||||
and nothing else changes.
|
||||
4. If something outside Cross Events still calls `/api/…`, it will get `410` after the update — look
|
||||
for those in the nginx access log before updating (`grep -v '/api/v2/' access.log | grep ' /api/'`,
|
||||
leaving out the webhook and link addresses above).
|
||||
5. Passwords are re-hashed with a stronger method the next time each person signs in; nothing to do.
|
||||
|
||||
## Every v1 address and its v2 replacement
|
||||
|
||||
`{id}` is the record's id. `{a|b}` means either. Methods that changed are shown in the v2 column.
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `GET /api/events` | `GET /api/v2/events` |
|
||||
| `GET /api/events/{id}` | `GET /api/v2/events/{id}/full` |
|
||||
| `GET /api/events/{id}/ics` | `GET /api/v2/events/{id}/calendar.ics` |
|
||||
| `GET /api/events/by-alias/{id}` | `GET /api/v2/events/by-alias/{id}` |
|
||||
| `POST /api/events/{id}/options` | `POST /api/v2/events/{id}/ticket-options` |
|
||||
| `PUT /api/events/options/{id}` | `PATCH /api/v2/ticket-options/{id}` |
|
||||
| `DELETE /api/events/options/{id}` | `DELETE /api/v2/ticket-options/{id}` |
|
||||
| `POST /api/events/options/{id}/variants` | `POST /api/v2/ticket-options/{id}/variants` |
|
||||
| `PUT /api/events/variants/{id}` | `PATCH /api/v2/variants/{id}` |
|
||||
| `DELETE /api/events/variants/{id}` | `DELETE /api/v2/variants/{id}` |
|
||||
| `GET /api/events/attachments/status` | `GET /api/v2/attachments/status` |
|
||||
| `POST /api/events/attachments/sync` | `POST /api/v2/attachments/sync` |
|
||||
| `GET /api/events/{id}/attachments` | `GET /api/v2/events/{id}/attachments` |
|
||||
| `POST /api/events/{id}/attachments` | `POST /api/v2/events/{id}/attachments` |
|
||||
| `DELETE /api/events/{id}/attachments/{id2}` | `DELETE /api/v2/events/{id}/attachments/{id2}` |
|
||||
| `GET /api/sections` | `GET /api/v2/sections` |
|
||||
| `POST /api/sections` | `POST /api/v2/sections` |
|
||||
| `PUT /api/sections/{id}` | `PATCH /api/v2/sections/{id}` |
|
||||
| `DELETE /api/sections/{id}` | `DELETE /api/v2/sections/{id}` |
|
||||
| `GET /api/events/{id}/costs` | `GET /api/v2/events/{id}/costs` |
|
||||
| `POST /api/events/{id}/costs` | `POST /api/v2/events/{id}/costs` |
|
||||
| `PUT /api/costs/{id}` | `PATCH /api/v2/costs/{id}` |
|
||||
| `DELETE /api/costs/{id}` | `DELETE /api/v2/costs/{id}` |
|
||||
| `POST /api/uploads/{event-image|logo|favicon}` | `POST /api/v2/uploads/{event-image|logo|favicon}` |
|
||||
| `GET /api/events/all` | `GET /api/v2/events/manage` |
|
||||
| `POST /api/events` | `POST /api/v2/events` |
|
||||
| `PUT /api/events/{id}` | `PATCH /api/v2/events/{id}` |
|
||||
| `DELETE /api/events/{id}` | `DELETE /api/v2/events/{id}` |
|
||||
| `POST /api/events/{id}/copy-from` | `POST /api/v2/events/{id}/copy-from` |
|
||||
| `GET /api/events/{id}/notify-recipients` | `GET /api/v2/events/{id}/notify-recipients` |
|
||||
| `PUT /api/events/{id}/notify-recipients` | `PUT /api/v2/events/{id}/notify-recipients` |
|
||||
| `POST /api/webhooks/{yoco|payfast|whatsapp}` | `POST /api/v2/webhooks/{yoco|payfast|whatsapp}` |
|
||||
| `POST /api/whatsapp/webhook` | `POST /api/v2/webhooks/whatsapp` |
|
||||
| `POST /api/privacy/unsubscribe/{id}/one-click` | `POST /api/v2/unsubscribe/{id}/one-click` |
|
||||
| `GET /api/privacy/me` | `GET /api/v2/me/privacy` |
|
||||
| `PUT /api/privacy/me/news` | `PUT /api/v2/me/privacy/news` |
|
||||
| `GET /api/privacy/me/export` | `GET /api/v2/me/privacy/export` |
|
||||
| `POST /api/privacy/me/requests` | `POST /api/v2/me/privacy/requests` |
|
||||
| `POST /api/privacy/me/notice/accept` | `POST /api/v2/me/privacy/notice/accept` |
|
||||
| `GET /api/privacy/notice` | `GET /api/v2/privacy/notice` |
|
||||
| `GET /api/privacy/unsubscribe/{id}` | `GET /api/v2/unsubscribe/{id}` |
|
||||
| `POST /api/privacy/unsubscribe/{id}` | `POST /api/v2/unsubscribe/{id}` |
|
||||
| `GET /api/privacy/requests` | `GET /api/v2/privacy/requests` |
|
||||
| `POST /api/privacy/requests/{id}/{complete|reject}` | `POST /api/v2/privacy/requests/{id}/{complete|reject}` |
|
||||
| `GET /api/privacy/notice/draft` | `GET /api/v2/privacy/notice/draft` |
|
||||
| `PUT /api/privacy/notice/draft` | `PUT /api/v2/privacy/notice/draft` |
|
||||
| `DELETE /api/privacy/notice/draft` | `DELETE /api/v2/privacy/notice/draft` |
|
||||
| `POST /api/privacy/notice/{publish|builtin-reviewed}` | `POST /api/v2/privacy/notice/{publish|builtin-reviewed}` |
|
||||
| `GET /api/privacy/operators` | `GET /api/v2/privacy/operators` |
|
||||
| `POST /api/privacy/operators` | `POST /api/v2/privacy/operators` |
|
||||
| `PUT /api/privacy/operators/{id}` | `PUT /api/v2/privacy/operators/{id}` |
|
||||
| `DELETE /api/privacy/operators/{id}` | `DELETE /api/v2/privacy/operators/{id}` |
|
||||
| `GET /api/privacy/{consent-log|access-log|readiness|retention|breaches}` | `GET /api/v2/privacy/{consent-log|access-log|readiness|retention|breaches}` |
|
||||
| `PUT /api/privacy/retention` | `PUT /api/v2/privacy/retention` |
|
||||
| `POST /api/privacy/retention/run` | `POST /api/v2/privacy/retention/run` |
|
||||
| `POST /api/privacy/breaches` | `POST /api/v2/privacy/breaches` |
|
||||
| `PUT /api/privacy/breaches/{id}` | `PUT /api/v2/privacy/breaches/{id}` |
|
||||
| `GET /api/admin/{audit-log|jobs|impersonation-sessions}` | `GET /api/v2/admin/{audit-log|jobs|impersonation-sessions}` |
|
||||
| `POST /api/admin/jobs/{id}/run` | `POST /api/v2/admin/jobs/{id}/run` |
|
||||
| `POST /api/admin/impersonate/{end|log}` | `POST /api/v2/me/impersonation/{end|log}` |
|
||||
| `GET /api/admin/impersonate/current` | `GET /api/v2/me/impersonation` |
|
||||
| `POST /api/admin/impersonate/{id}` | `POST /api/v2/admin/impersonate/{id}` |
|
||||
| `GET /api/settings/needs-setup` | `GET /api/v2/setup/needed` |
|
||||
| `GET /api/settings/all` | `GET /api/v2/settings/all` |
|
||||
| `GET /api/settings` | `GET /api/v2/settings` |
|
||||
| `PUT /api/settings` | `PUT /api/v2/settings` |
|
||||
| `POST /api/settings/test-smtp` | `POST /api/v2/settings/test-smtp` |
|
||||
| `POST /api/setup/register` | `POST /api/v2/setup/register` |
|
||||
| `POST /api/setup` | `POST /api/v2/setup` |
|
||||
| `GET /api/hub/{status|notices}` | `GET /api/v2/hub/{status|notices}` |
|
||||
| `POST /api/hub/{connect|heartbeat|update-request|update-snooze}` | `POST /api/v2/hub/{connect|heartbeat|update-request|update-snooze}` |
|
||||
| `POST /api/hub/update-request/cancel` | `POST /api/v2/hub/update-request/cancel` |
|
||||
| `GET /api/outbound-webhooks` | `GET /api/v2/outbound-webhooks` |
|
||||
| `POST /api/outbound-webhooks` | `POST /api/v2/outbound-webhooks` |
|
||||
| `POST /api/outbound-webhooks/deliveries/{id}/resend` | `POST /api/v2/outbound-webhook-deliveries/{id}/resend` |
|
||||
| `PUT /api/outbound-webhooks/{id}` | `PATCH /api/v2/outbound-webhooks/{id}` |
|
||||
| `DELETE /api/outbound-webhooks/{id}` | `DELETE /api/v2/outbound-webhooks/{id}` |
|
||||
| `POST /api/outbound-webhooks/{id}/{rotate-secret|test}` | `POST /api/v2/outbound-webhooks/{id}/{rotate-secret|test}` |
|
||||
| `GET /api/outbound-webhooks/{id}/deliveries` | `GET /api/v2/outbound-webhooks/{id}/deliveries` |
|
||||
| `GET /api/backups` | `GET /api/v2/backups` |
|
||||
| `POST /api/backups/run` | `POST /api/v2/backups/run` |
|
||||
| `GET /api/backups/{id}/download` | `GET /api/v2/backups/{id}/download` |
|
||||
| `GET /api/{yoco|payfast}/config` | `GET /api/v2/payments/{yoco|payfast}/config` |
|
||||
| `PUT /api/{yoco|payfast}/config` | `PUT /api/v2/payments/{yoco|payfast}/config` |
|
||||
| `GET /api/yoco/webhooks` | `GET /api/v2/payments/yoco/webhooks` |
|
||||
| `POST /api/yoco/webhooks` | `POST /api/v2/payments/yoco/webhooks` |
|
||||
| `DELETE /api/yoco/webhooks/{id}` | `DELETE /api/v2/payments/yoco/webhooks/{id}` |
|
||||
| `POST /api/payfast/test` | `POST /api/v2/payments/payfast/test` |
|
||||
| `POST /api/users/login/2fa/resend` | `POST /api/v2/auth/login/code/resend` |
|
||||
| `POST /api/users/forgot` | `POST /api/v2/auth/password/forgot` |
|
||||
| `POST /api/users/reset` | `POST /api/v2/auth/password/reset` |
|
||||
| `POST /api/users/activate` | `POST /api/v2/auth/activate` |
|
||||
| `POST /api/users/revoke-sessions` | `POST /api/v2/me/sessions/revoke` |
|
||||
| `POST /api/users/close-account` | `POST /api/v2/me/close` |
|
||||
| `GET /api/users/activity` | `GET /api/v2/me/activity` |
|
||||
| `POST /api/users/verify-password` | `POST /api/v2/me/verify-password` |
|
||||
| `POST /api/users/2fa/{enable|confirm|disable}` | `POST /api/v2/me/two-factor/{enable|confirm|disable}` |
|
||||
| `GET /api/users/check-exists` | `GET /api/v2/users/exists` |
|
||||
| `POST /api/users/{id}/revoke-sessions` | `POST /api/v2/users/{id}/sessions/revoke` |
|
||||
| `POST /api/users/{id}/anonymize` | `POST /api/v2/users/{id}/anonymize` |
|
||||
| `GET /api/users` | `GET /api/v2/users` |
|
||||
| `GET /api/users/{?!profile$}{id}` | `GET /api/v2/users/{?!profile$}` |
|
||||
| `PUT /api/users/{?!profile$}{id}` | `PATCH /api/v2/users/{?!profile$}` |
|
||||
| `DELETE /api/users/{id}` | `DELETE /api/v2/users/{id}` |
|
||||
| `GET /api/events/admin/all` | `GET /api/v2/admin/events` |
|
||||
| `POST /api/events/{id}/{email|whatsapp}-attendees` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees` |
|
||||
| `POST /api/events/{id}/{email|whatsapp}-attendees/schedule` | `POST /api/v2/events/{id}/{email|whatsapp}-attendees/schedule` |
|
||||
| `POST /api/broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/email/{preview|send|schedule}` |
|
||||
| `POST /api/whatsapp-broadcasts/{preview|send|schedule}` | `POST /api/v2/broadcasts/whatsapp/{preview|send|schedule}` |
|
||||
| `GET /api/scheduled-emails` | `GET /api/v2/scheduled-messages` |
|
||||
| `PATCH /api/scheduled-emails/{id}` | `PATCH /api/v2/scheduled-messages/{id}` |
|
||||
| `DELETE /api/scheduled-emails/{id}` | `DELETE /api/v2/scheduled-messages/{id}` |
|
||||
| `POST /api/automations/schedule` | `POST /api/v2/automations/schedule` |
|
||||
| `GET /api/whatsapp/{config|status|qr}` | `GET /api/v2/whatsapp/{config|status|qr}` |
|
||||
| `POST /api/whatsapp/config` | `GET/PUT /api/v2/whatsapp/config` |
|
||||
| `POST /api/whatsapp/create-instance` | `POST /api/v2/whatsapp/instance` |
|
||||
| `POST /api/whatsapp/delete-instance` | `POST /api/v2/whatsapp/instance` |
|
||||
| `POST /api/whatsapp/{request-code|logout|start|restart}` | `POST /api/v2/whatsapp/{request-code|logout|start|restart}` |
|
||||
| `GET /api/banner` | `GET /api/v2/banner` |
|
||||
| `POST /api/banner` | `GET/PUT /api/v2/banner` |
|
||||
| `GET /api/saved-filters` | `GET /api/v2/saved-filters` |
|
||||
| `POST /api/saved-filters` | `POST /api/v2/saved-filters` |
|
||||
| `PUT /api/saved-filters/{id}` | `PUT /api/v2/saved-filters/{id}` |
|
||||
| `DELETE /api/saved-filters/{id}` | `DELETE /api/v2/saved-filters/{id}` |
|
||||
| `GET /api/dashboard/{today|search}` | `GET /api/v2/dashboard/{today|search}` |
|
||||
| `GET /api/stats/{staff|supervisor|admin|overview}` | `GET /api/v2/stats/{staff|supervisor|admin|overview}` |
|
||||
| `POST /api/analytics/track` | `POST /api/v2/analytics/track` |
|
||||
| `GET /api/analytics/funnel` | `GET /api/v2/analytics/funnel` |
|
||||
| `POST /api/reports/{pdf|excel|email|whatsapp}` | `POST /api/v2/reports/{pdf|excel|email|whatsapp}` |
|
||||
| `GET /api/kids/me` | `GET /api/v2/me/family` |
|
||||
| `POST /api/kids/me` | `POST /api/v2/me/family` |
|
||||
| `PUT /api/kids/me` | `PATCH /api/v2/me/family` |
|
||||
| `DELETE /api/kids/me` | `DELETE /api/v2/me/family` |
|
||||
| `POST /api/kids/me/{children|adults}` | `POST /api/v2/me/family/{children|adults}` |
|
||||
| `PUT /api/kids/me/{children|adults}/{id}` | `PUT /api/v2/me/family/{children|adults}/{id}` |
|
||||
| `DELETE /api/kids/me/{children|adults}/{id}` | `DELETE /api/v2/me/family/{children|adults}/{id}` |
|
||||
| `GET /api/kids/setup-events` | `GET /api/v2/kids/setup-events` |
|
||||
| `GET /api/kids/events` | `GET /api/v2/kids/events` |
|
||||
| `GET /api/kids/events/{id}/{settings|board|families|offline}` | `GET /api/v2/events/{id}/kids/{settings|board|families|offline}` |
|
||||
| `PUT /api/kids/events/{id}/settings` | `PUT /api/v2/events/{id}/kids/settings` |
|
||||
| `POST /api/kids/events/{id}/{families|check-in|check-out|duty|sync}` | `POST /api/v2/events/{id}/kids/{families|check-in|check-out|duty|sync}` |
|
||||
| `GET /api/kids/events/{id}/families/qr/{id2}` | `GET /api/v2/events/{id}/kids/families/qr/{id2}` |
|
||||
| `GET /api/kids/events/{id}/families/{id2}` | `GET /api/v2/events/{id}/kids/families/{id2}` |
|
||||
| `POST /api/kids/events/{id}/families/{id2}/{parents|children|adults}` | `POST /api/v2/events/{id}/kids/families/{id2}/{parents|children|adults}` |
|
||||
| `PUT /api/kids/events/{id}/families/{id2}/children/{id3}` | `PUT /api/v2/events/{id}/kids/families/{id2}/children/{id3}` |
|
||||
| `GET /api/kids/events/{id}/code/{id2}` | `GET /api/v2/events/{id}/kids/code/{id2}` |
|
||||
| `POST /api/kids/check-ins/{id}/{override|page}` | `POST /api/v2/kids/check-ins/{id}/{override|page}` |
|
||||
| `PUT /api/kids/check-ins/{id}/room` | `PUT /api/v2/kids/check-ins/{id}/room` |
|
||||
| `POST /api/kids/alerts/{id}/resolve` | `POST /api/v2/kids/alerts/{id}/resolve` |
|
||||
| `GET /api/volunteers/me` | `GET /api/v2/me/volunteering` |
|
||||
| `GET /api/volunteers/events/{id}` | `GET /api/v2/events/{id}/volunteer-roles` |
|
||||
| `POST /api/volunteers/roles/{id}/sign-up` | `POST /api/v2/volunteer-roles/{id}/sign-up` |
|
||||
| `DELETE /api/volunteers/slots/{id}` | `DELETE /api/v2/volunteer-slots/{id}` |
|
||||
| `POST /api/volunteers/slots/{id}/swap` | `POST /api/v2/volunteer-slots/{id}/swap` |
|
||||
| `DELETE /api/volunteers/swaps/{id}` | `DELETE /api/v2/volunteer-swaps/{id}` |
|
||||
| `POST /api/volunteers/swaps/{id}/accept` | `POST /api/v2/volunteer-swaps/{id}/accept` |
|
||||
| `POST /api/volunteers/check-in` | `POST /api/v2/volunteering/check-in` |
|
||||
| `GET /api/volunteers/manage/events` | `GET /api/v2/volunteering/events` |
|
||||
| `GET /api/volunteers/manage/hours` | `GET /api/v2/volunteering/hours` |
|
||||
| `GET /api/volunteers/manage/events/{id}` | `GET /api/v2/events/{id}/volunteers` |
|
||||
| `GET /api/volunteers/manage/events/{id}/{qr|settings}` | `GET /api/v2/events/{id}/volunteers/{qr|settings}` |
|
||||
| `PUT /api/volunteers/manage/events/{id}/settings` | `PUT /api/v2/events/{id}/volunteers/settings` |
|
||||
| `POST /api/volunteers/manage/events/{id}/roles` | `POST /api/v2/events/{id}/volunteer-roles` |
|
||||
| `PUT /api/volunteers/manage/roles/{id}` | `PATCH /api/v2/volunteer-roles/{id}` |
|
||||
| `DELETE /api/volunteers/manage/roles/{id}` | `DELETE /api/v2/volunteer-roles/{id}` |
|
||||
| `POST /api/volunteers/manage/roles/{id}/assign` | `POST /api/v2/volunteer-roles/{id}/assign` |
|
||||
| `POST /api/volunteers/manage/slots/{id}/check-in` | `POST /api/v2/volunteer-slots/{id}/check-in` |
|
||||
| `DELETE /api/volunteers/manage/slots/{id}` | `POST /api/v2/volunteer-slots/{id}/remove` |
|
||||
| `GET /api/tickets/mytickets` | `GET /api/v2/me/tickets` |
|
||||
| `POST /api/tickets/email` | `POST /api/v2/me/tickets/email` |
|
||||
| `GET /api/tickets/scans/recent` | `GET /api/v2/tickets/scans/recent` |
|
||||
| `GET /api/tickets/scans/stats` | `GET /api/v2/tickets/scans/stats` |
|
||||
| `POST /api/tickets/send-to` | `POST /api/v2/tickets/send` |
|
||||
| `POST /api/tickets/generate` | `POST /api/v2/tickets/generate` |
|
||||
| `PUT /api/tickets/email-sent` | `POST /api/v2/tickets/email-sent` |
|
||||
| `GET /api/tickets/qr/{id}` | `GET /api/v2/tickets/qr/{id}` |
|
||||
| `GET /api/tickets/event/{id}` | `GET /api/v2/events/{id}/tickets` |
|
||||
| `GET /api/tickets/transfer-offer/{id}` | `GET /api/v2/ticket-transfers/{id}` |
|
||||
| `POST /api/tickets/transfer-offer/{id}/{accept|decline}` | `POST /api/v2/ticket-transfers/{id}/{accept|decline}` |
|
||||
| `GET /api/tickets` | `GET /api/v2/tickets` |
|
||||
| `GET /api/tickets/{id}/transfers` | `GET /api/v2/tickets/{id}/transfers` |
|
||||
| `POST /api/tickets/{id}/transfer` | `POST /api/v2/tickets/{id}/transfer` |
|
||||
| `DELETE /api/tickets/{id}/transfer` | `DELETE /api/v2/tickets/{id}/transfer` |
|
||||
| `GET /api/tickets/{id}` | `GET /api/v2/tickets/{id}` |
|
||||
| `GET /api/scan-locks` | `GET /api/v2/scan-locks` |
|
||||
| `POST /api/scan-locks` | `POST /api/v2/scan-locks` |
|
||||
| `GET /api/scan-locks/reports` | `GET /api/v2/scan-locks/reports` |
|
||||
| `POST /api/scan-locks/reports/{id}/review` | `POST /api/v2/scan-locks/reports/{id}/review` |
|
||||
| `GET /api/scan-locks/{id}/snapshot` | `GET /api/v2/scan-locks/{id}/snapshot` |
|
||||
| `POST /api/scan-locks/{id}/{sync|force-release}` | `POST /api/v2/scan-locks/{id}/{sync|force-release}` |
|
||||
| `DELETE /api/scan-locks/{id}` | `DELETE /api/v2/scan-locks/{id}` |
|
||||
| `GET /api/attendance/certificates/verify/{id}` | `GET /api/v2/certificates/verify/{id}` |
|
||||
| `GET /api/attendance/certificates/mine` | `GET /api/v2/me/certificates` |
|
||||
| `POST /api/attendance/certificates/preview` | `POST /api/v2/certificates/preview` |
|
||||
| `GET /api/attendance/certificates/{id}/pdf` | `GET /api/v2/certificates/{id}/pdf` |
|
||||
| `POST /api/attendance/certificates/{id}/revoke` | `POST /api/v2/certificates/{id}/revoke` |
|
||||
| `GET /api/attendance/{id}/export` | `GET /api/v2/events/{id}/attendance/export` |
|
||||
| `POST /api/attendance/{id}/mark` | `POST /api/v2/events/{id}/attendance` |
|
||||
| `POST /api/attendance/{id}/certificates` | `POST /api/v2/events/{id}/certificates` |
|
||||
| `POST /api/attendance/{id}/certificates/send` | `POST /api/v2/events/{id}/certificates/send` |
|
||||
| `GET /api/attendance/{id}/certificates.pdf` | `GET /api/v2/events/{id}/certificates.pdf` |
|
||||
| `GET /api/attendance/{id}` | `GET /api/v2/events/{id}/attendance` |
|
||||
| `GET /api/allocations/my-groups` | `GET /api/v2/me/allocation-groups` |
|
||||
| `GET /api/allocations/event/{id}` | `GET /api/v2/events/{id}/allocations` |
|
||||
| `POST /api/allocations/sets` | `POST /api/v2/allocation-sets` |
|
||||
| `PUT /api/allocations/sets/{id}` | `PATCH /api/v2/allocation-sets/{id}` |
|
||||
| `DELETE /api/allocations/sets/{id}` | `DELETE /api/v2/allocation-sets/{id}` |
|
||||
| `GET /api/allocations/sets/{id}/lists` | `GET /api/v2/allocation-sets/{id}/lists` |
|
||||
| `POST /api/allocations/sets/{id}/{groups|auto}` | `POST /api/v2/allocation-sets/{id}/{groups|auto}` |
|
||||
| `PUT /api/allocations/sets/{id}/assignments` | `PUT /api/v2/allocation-sets/{id}/assignments` |
|
||||
| `DELETE /api/allocations/sets/{id}/assignments` | `DELETE /api/v2/allocation-sets/{id}/assignments` |
|
||||
| `PUT /api/allocations/groups/{id}` | `PATCH /api/v2/allocation-groups/{id}` |
|
||||
| `DELETE /api/allocations/groups/{id}` | `DELETE /api/v2/allocation-groups/{id}` |
|
||||
| `GET /api/payments/providers` | `GET /api/v2/payments/providers` |
|
||||
| `GET /api/payments/providers/settings` | `GET /api/v2/payments/providers/settings` |
|
||||
| `PUT /api/payments/providers/settings` | `PUT /api/v2/payments/providers/settings` |
|
||||
| `GET /api/payments/pay/{id}` | `GET /api/v2/payment-links/{id}` |
|
||||
| `POST /api/payments/pay/{id}` | `POST /api/v2/payment-links/{id}` |
|
||||
| `GET /api/payments/checkouts/open` | `GET /api/v2/payments/open-checkouts` |
|
||||
| `POST /api/payments/checkouts/check` | `POST /api/v2/payments/open-checkouts/check` |
|
||||
| `POST /api/payments/[yoco-]checkout/send` | `POST /api/v2/registrations/{id}/payment-link` |
|
||||
| `GET /api/payments/mypayments` | `GET /api/v2/me/payments` |
|
||||
| `GET /api/payments/admin/stats` | `GET /api/v2/payments/stats` |
|
||||
| `GET /api/payments/registration/{id}` | `GET /api/v2/registrations/{id}/payments` |
|
||||
| `GET /api/payments/event/{id}` | `GET /api/v2/events/{id}/payments` |
|
||||
| `PUT /api/payments/assign-donation` | `POST /api/v2/payments/donations/assign` |
|
||||
| `POST /api/payments/unassign-donation` | `POST /api/v2/payments/donations/unassign` |
|
||||
| `POST /api/payments/refund` | `POST /api/v2/payments/refunds` |
|
||||
| `POST /api/payments/{id}/send-receipt` | `POST /api/v2/payments/{id}/receipt` |
|
||||
| `GET /api/payments` | `GET /api/v2/payments` |
|
||||
| `POST /api/payments` | `POST /api/v2/payments` |
|
||||
| `GET /api/payments/{id}` | `GET /api/v2/payments/{id}` |
|
||||
| `GET /api/cashups/audit` | `GET /api/v2/cashups/audit` |
|
||||
| `GET /api/cashups/event/{id}` | `GET /api/v2/events/{id}/cashup` |
|
||||
| `GET /api/cashups/event/{id}/cash-by-user` | `GET /api/v2/events/{id}/cashup/cash-by-user` |
|
||||
| `PUT /api/cashups/event/{id}/person-cash/{id2}` | `PUT /api/v2/events/{id}/cashup/person-cash/{id2}` |
|
||||
| `PUT /api/cashups/event/{id}/draft` | `PUT /api/v2/events/{id}/cashup/draft` |
|
||||
| `POST /api/cashups/event/{id}/close` | `POST /api/v2/events/{id}/cashup/close` |
|
||||
| `POST /api/cashups/event/{id}/reopen` | `POST /api/v2/events/{id}/cashup/reopen` |
|
||||
| `POST /api/cashups/lines/{id}/pending` | `POST /api/v2/cashup-lines/{id}/pending` |
|
||||
| `GET /api/{provider|yoco}-transactions` | `GET /api/v2/provider-transactions` |
|
||||
| `GET /api/{provider|yoco}-transactions/unreconciled` | `GET /api/v2/provider-transactions/unreconciled` |
|
||||
| `POST /api/{?:provider|yoco}-transactions/{id}/reconcile` | `POST /api/v2/provider-transactions/{?:provider|yoco}/reconcile` |
|
||||
| `POST /api/{?:provider|yoco}-transactions/{id}/ignore` | `POST /api/v2/provider-transactions/{?:provider|yoco}/ignore` |
|
||||
| `POST /api/waitlist` | `POST /api/v2/waitlist` |
|
||||
| `GET /api/waitlist/mine` | `GET /api/v2/me/waitlist` |
|
||||
| `GET /api/waitlist/claim/{id}` | `GET /api/v2/waitlist/claims/{id}` |
|
||||
| `GET /api/waitlist/events/{id}` | `GET /api/v2/events/{id}/waitlist` |
|
||||
| `POST /api/waitlist/{id}/{offer|move|remove}` | `POST /api/v2/waitlist/{id}/{offer|move|remove}` |
|
||||
| `DELETE /api/waitlist/{id}` | `DELETE /api/v2/waitlist/{id}` |
|
||||
| `GET /api/sponsorships/event/{id}` | `GET /api/v2/events/{id}/sponsorship` |
|
||||
| `GET /api/sponsorships/event/{id}/pool` | `GET /api/v2/events/{id}/sponsorship/pool` |
|
||||
| `POST /api/sponsorships/checkout` | `POST /api/v2/sponsorships/checkout` |
|
||||
| `GET /api/consent/events/{id}` | `GET /api/v2/events/{id}/consent` |
|
||||
| `GET /api/consent/events/{id}/medical` | `GET /api/v2/events/{id}/consent/medical` |
|
||||
| `POST /api/consent/events/{id}/{resend-pending|rerequest}` | `POST /api/v2/events/{id}/consent/{resend-pending|rerequest}` |
|
||||
| `POST /api/consent/requests/{id}/resend` | `POST /api/v2/consent-requests/{id}/resend` |
|
||||
| `GET /api/consent/{id}` | `GET /api/v2/consent/{id}` |
|
||||
| `POST /api/consent/{id}` | `POST /api/v2/consent/{id}` |
|
||||
| `GET /api/forms/responses` | `GET /api/v2/form-responses` |
|
||||
| `GET /api/feedback/summary` | `GET /api/v2/feedback/summary` |
|
||||
| `GET /api/feedback/public/{id}` | `GET /api/v2/feedback-forms/{id}` |
|
||||
| `POST /api/feedback/public/{id}` | `POST /api/v2/feedback-forms/{id}` |
|
||||
| `GET /api/feedback/event/{id}` | `GET /api/v2/events/{id}/feedback` |
|
||||
| `PUT /api/feedback/event/{id}` | `PUT /api/v2/events/{id}/feedback` |
|
||||
| `DELETE /api/feedback/event/{id}` | `DELETE /api/v2/events/{id}/feedback` |
|
||||
| `POST /api/feedback/event/{id}/send-now` | `POST /api/v2/events/{id}/feedback/send-now` |
|
||||
| `GET /api/registrations` | `GET /api/v2/registrations` |
|
||||
| `GET /api/registrations/myregistrations` | `GET /api/v2/me/registrations/full` |
|
||||
| `GET /api/registrations/event/{id}` | `GET /api/v2/events/{id}/registrations` |
|
||||
| `POST /api/registrations/manual` | `GET /api/v2/registrations/manual` |
|
||||
| `POST /api/registrations/bulk` | `GET /api/v2/registrations/bulk` |
|
||||
| `PUT /api/registrations/{id}/options` | `PUT /api/v2/registrations/{id}/items` |
|
||||
| `POST /api/registrations/{id}/send-invoice` | `POST /api/v2/registrations/{id}/invoice` |
|
||||
| `POST /api/registrations/{id}/coupon` | `POST /api/v2/registrations/{id}/coupon` |
|
||||
| `DELETE /api/registrations/{id}/coupon` | `DELETE /api/v2/registrations/{id}/coupon` |
|
||||
| `PUT /api/registrations/{id}/attendees` | `PUT /api/v2/registrations/{id}/attendees` |
|
||||
| `POST /api/registrations/{id}/attendees/{id2}/send-form-link` | `POST /api/v2/registrations/{id}/attendees/{id2}/form-link` |
|
||||
| `GET /api/registrations/{id}/forms/draft` | `GET /api/v2/registrations/{id}/form-draft` |
|
||||
| `PUT /api/registrations/{id}/forms/draft` | `PUT /api/v2/registrations/{id}/form-draft` |
|
||||
| `POST /api/registrations/{id}/forms/responses` | `POST /api/v2/registrations/{id}/form-responses` |
|
||||
| `PUT /api/registrations/{id}/forms/responses` | `PUT /api/v2/registrations/{id}/form-responses` |
|
||||
| `GET /api/registrations/{id}` | `GET /api/v2/registrations/{id}/full` |
|
||||
| `PUT /api/registrations/{id}` | `PATCH /api/v2/registrations/{id}/status` |
|
||||
| `GET /api/series` | `GET /api/v2/series` |
|
||||
| `POST /api/series` | `POST /api/v2/series` |
|
||||
| `GET /api/series/by-slug/{id}` | `GET /api/v2/series/by-slug/{id}` |
|
||||
| `GET /api/series/{id}/compare` | `GET /api/v2/series/{id}/compare` |
|
||||
| `POST /api/series/{id}/follow` | `PUT/DELETE /api/v2/series/{id}/follow` |
|
||||
| `DELETE /api/series/{id}/follow` | `DELETE /api/v2/series/{id}/follow` |
|
||||
| `PUT /api/series/{id}` | `PATCH /api/v2/series/{id}` |
|
||||
| `DELETE /api/series/{id}` | `DELETE /api/v2/series/{id}` |
|
||||
| `GET /api/season-passes/mine` | `GET /api/v2/me/season-passes` |
|
||||
| `POST /api/season-passes/purchases/{id}/checkout` | `POST /api/v2/season-pass-purchases/{id}/checkout` |
|
||||
| `POST /api/season-passes/{id}/buy` | `POST /api/v2/season-passes/{id}/buy` |
|
||||
| `GET /api/season-passes` | `GET /api/v2/season-passes` |
|
||||
| `POST /api/season-passes` | `POST /api/v2/season-passes` |
|
||||
| `PUT /api/season-passes/{id}` | `PATCH /api/v2/season-passes/{id}` |
|
||||
| `DELETE /api/season-passes/{id}` | `DELETE /api/v2/season-passes/{id}` |
|
||||
| `POST /api/bundles/quote` | `PATCH/DELETE /api/v2/bundles/quote` |
|
||||
| `GET /api/bundles/event/{id}` | `GET /api/v2/events/{id}/bundles` |
|
||||
| `POST /api/bundles/event/{id}` | `POST /api/v2/events/{id}/bundles` |
|
||||
| `PUT /api/bundles/{id}` | `PATCH /api/v2/bundles/{id}` |
|
||||
| `DELETE /api/bundles/{id}` | `DELETE /api/v2/bundles/{id}` |
|
||||
| `POST /api/coupons/validate` | `GET/PATCH /api/v2/coupons/check` |
|
||||
| `GET /api/coupons/redemptions` | `GET /api/v2/coupons/redemptions` |
|
||||
| `GET /api/coupons` | `GET /api/v2/coupons` |
|
||||
| `POST /api/coupons` | `POST /api/v2/coupons` |
|
||||
| `GET /api/coupons/{id}` | `GET /api/v2/coupons/{id}` |
|
||||
| `PUT /api/coupons/{id}` | `PATCH /api/v2/coupons/{id}` |
|
||||
| `GET /api/campuses` | `GET /api/v2/campuses` |
|
||||
| `GET /api/campuses/admin` | `GET /api/v2/campuses/admin` |
|
||||
| `GET /api/campuses/by-slug/{id}` | `GET /api/v2/campuses/by-slug/{id}` |
|
||||
| `PUT /api/campuses/settings` | `PUT /api/v2/campuses/enabled` |
|
||||
| `PUT /api/campuses/order` | `PUT /api/v2/campuses/order` |
|
||||
| `POST /api/campuses` | `POST /api/v2/campuses` |
|
||||
| `GET /api/campuses/{id}/audience` | `GET /api/v2/campuses/{id}/audience` |
|
||||
| `PUT /api/campuses/{id}` | `PATCH /api/v2/campuses/{id}` |
|
||||
| `DELETE /api/campuses/{id}` | `DELETE /api/v2/campuses/{id}` |
|
||||
| `GET /api/users/profile` | `GET /api/v2/me` |
|
||||
| `PUT /api/users/profile` | `PATCH /api/v2/me` |
|
||||
| `POST /api/users` | `POST /api/v2/auth/signup` |
|
||||
| `POST /api/users/login` | `POST /api/v2/auth/login` |
|
||||
| `POST /api/users/login/2fa` | `POST /api/v2/auth/login/code` |
|
||||
| `POST /api/registrations` | `POST /api/v2/registrations` |
|
||||
| `DELETE /api/registrations/{id}` | `POST /api/v2/registrations/{id}/cancel` |
|
||||
| `POST /api/payments/checkout` | `POST /api/v2/registrations/{id}/checkout` |
|
||||
| `GET /api/tickets/scan-preview/{id}` | `GET /api/v2/checkin/{id}` |
|
||||
| `POST /api/tickets/scan/{id}` | `POST /api/v2/checkin/{id}` |
|
||||
@@ -20,7 +20,8 @@ The backend provides a RESTful API for managing users, events, registrations, pa
|
||||
- Static uploads for event images
|
||||
|
||||
Documentation: see Backend API Documentation and Backend README for environment setup and endpoints.
|
||||
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
|
||||
- API reference: `/docs/v2` on the running backend (OpenAPI; admin token) — ./backend/API_DOCUMENTATION.md
|
||||
- Moving to 2.0 (v1 API removed): ./MIGRATING-TO-2.md
|
||||
- Backend README: ./backend/README.md
|
||||
|
||||
### Frontend
|
||||
@@ -107,7 +108,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 +138,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
|
||||
@@ -155,7 +156,8 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||
|
||||
## Useful Links
|
||||
|
||||
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
|
||||
- API reference: `/docs/v2` on the running backend (OpenAPI; admin token) — ./backend/API_DOCUMENTATION.md
|
||||
- Moving to 2.0 (v1 API removed): ./MIGRATING-TO-2.md
|
||||
- Backend README: ./backend/README.md
|
||||
- Frontend README: ./frontend/README.md
|
||||
- Site Settings — defaults reference: ./SETTINGS.md
|
||||
@@ -163,6 +165,9 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
> Older notes, kept for history. The `/api/…` paths they mention are API v1, removed in 2.0 —
|
||||
> MIGRATING-TO-2.md lists each one's v2 replacement. Since 1.33 the changes are in CHANGELOG.md.
|
||||
|
||||
### Sold-out handling, tiered stock warnings & UI polish
|
||||
|
||||
**Backend**
|
||||
|
||||
+3
-3
@@ -60,7 +60,7 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
|
||||
## Payments
|
||||
|
||||
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
|
||||
Managed in the Payments tab (through `/api/v2/payments/providers/settings` and `/api/v2/payments/yoco/config`, not the generic settings endpoints).
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
@@ -89,7 +89,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/campuses/settings` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
|
||||
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/v2/campuses/enabled` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
|
||||
|
||||
## Cross Events Hub (licence)
|
||||
|
||||
@@ -106,7 +106,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/whatsapp/webhook?secret=…`). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
|
||||
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/v2/webhooks/whatsapp?secret=…`; set-ups from before 1.51.16 use `/api/whatsapp/webhook?secret=…`, which still works). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
|
||||
+143
@@ -12,6 +12,149 @@ 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".
|
||||
-->
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- **AI helpers.** Where you already work, a button can now do the first draft for you: describe
|
||||
an event in a sentence and the new-event form is filled in; turn a short note into a friendly
|
||||
email or WhatsApp message; get a plain-language summary under a report or of an event's
|
||||
feedback; or improve, shorten or correct a piece of text. You always see the suggestion first —
|
||||
nothing is saved or sent until you do it. The buttons appear when your licence includes an AI
|
||||
allowance (ask us about it); Site settings → AI shows what's been used this month. Only what a
|
||||
helper needs is sent: email addresses and phone numbers in your text stay on your site, reports
|
||||
are only summarised when they list no people, and feedback goes without names.
|
||||
- **Stronger password protection.** Passwords are now stored with a stronger, modern method. Nobody
|
||||
needs to do anything: each person's password is upgraded the next time they sign in.
|
||||
- **The site banner keeps time.** A banner with start and end times now appears and disappears at
|
||||
those times on pages that are already open.
|
||||
- **For anyone with their own integration** (a script or website plug-in that talks to Cross Events
|
||||
directly): the old version of the connection was retired. Payments, WhatsApp replies and links in
|
||||
emails you've already sent keep working. Whoever looks after your integration can ask us for the
|
||||
migration guide.
|
||||
|
||||
## 1.52.3
|
||||
|
||||
- **A licence check fix.** When a site's licence has run out or isn't connected, the site is
|
||||
meant to go read-only (tickets still scan and all data can still be viewed and exported). Since
|
||||
1.51.15 that wasn't being applied; it is again. Sites with an active licence see no change.
|
||||
|
||||
## 1.52.2
|
||||
|
||||
- **Safer updates.** If files on your server were changed by hand, an update now stops straight
|
||||
away and says which files, instead of failing part-way. Nothing else changes.
|
||||
|
||||
## 1.52.1
|
||||
|
||||
- **Better documentation for developers.** If someone builds on your Cross Events site (for example
|
||||
an integration or a website plug-in), the API reference now explains every part of it in full.
|
||||
Nothing changes in how the site works.
|
||||
|
||||
## 1.52.0 — urgent
|
||||
|
||||
- **Paying at the door always gives people their tickets now.** On events with a required
|
||||
attendee form, people registered at the door or in Manual registration were paid but got no
|
||||
tickets, so they couldn't be checked in. The form now pops up to fill in with them — or tap
|
||||
**Fill in later**: the tickets are issued straight away and they're sent a link to fill in the
|
||||
form afterwards.
|
||||
- **New: Corrections (for admins).** Pick any event, even one that's over, to see who's missing
|
||||
tickets, still has a form to fill in or owes money. Issue all missing tickets in one click, give
|
||||
someone their tickets now and let them pay later, make a ticket free, or check people in after
|
||||
the event.
|
||||
- The self-service kiosk asks for exactly the forms still needed, and shows headings on the form
|
||||
as text instead of an empty box.
|
||||
- People booked on several sessions now fill in one form each, not one per session.
|
||||
|
||||
## 1.51.17 — security update
|
||||
|
||||
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
|
||||
let anyone find out whether an email address has an account here.
|
||||
|
||||
## 1.51.16
|
||||
|
||||
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
|
||||
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
|
||||
the new addresses. Links in emails you've already sent keep working.
|
||||
|
||||
## 1.51.15
|
||||
|
||||
- **The app now talks to the server only through its new version.** Privacy settings, the admin
|
||||
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
|
||||
as before.
|
||||
|
||||
## 1.51.14
|
||||
|
||||
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
|
||||
saved filters and report files now run on the new version of the server's connection to the
|
||||
app. They work just as before.
|
||||
|
||||
## 1.51.13
|
||||
|
||||
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
|
||||
the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.12
|
||||
|
||||
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
|
||||
certificates and group allocations now run on the new version of the server's connection to the
|
||||
app. They work just as before.
|
||||
|
||||
## 1.51.11
|
||||
|
||||
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
|
||||
feedback now run on the new version of the server's connection to the app. They work just as
|
||||
before.
|
||||
|
||||
## 1.51.10
|
||||
|
||||
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
|
||||
bulk actions — now run on the new version of the server's connection to the app. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.9
|
||||
|
||||
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
|
||||
now run on the new version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.8
|
||||
|
||||
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
|
||||
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
|
||||
|
||||
+11
-1166
File diff suppressed because it is too large
Load Diff
Generated
+668
-1486
File diff suppressed because it is too large
Load Diff
+15
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.1",
|
||||
"version": "2.0.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -17,37 +17,38 @@
|
||||
"set-demo": "node scripts/set-demo.js",
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"openapi": "tsx scripts/openapi.ts"
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"ai:eval": "tsx scripts/ai-eval.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@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",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.0.16",
|
||||
"multer": "^2.5.0",
|
||||
"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",
|
||||
"swagger-ui-dist": "^5.34.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.43",
|
||||
@@ -72,5 +73,8 @@
|
||||
"@parcel/watcher": false,
|
||||
"@scarf/scarf": false,
|
||||
"esbuild": false
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ai_settings_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"helper" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"chargedCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_createdAt_idx" ON "AiCall"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_userId_createdAt_idx" ON "AiCall"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- 2.0 AI helpers: Anthropic in the operator register, off until the site uses AI.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Anthropic (Claude), through the software provider''s Hub', 'AI helpers for staff: drafting events and messages, improving text, summarising reports and feedback', 'The text a staff member asks to draft or improve, report figures, and feedback comments with names and contact details removed. Not kept by the software provider; Anthropic''s commercial terms apply', 'United States', 'https://www.anthropic.com/legal/privacy', false, 9, CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendee_form_waived';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'tickets_issued_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'registration_complimentary';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'checkin_corrected';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "formWaivedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "formWaivedById" TEXT,
|
||||
ADD COLUMN "ticketsReleasedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "ticketsReleasedById" TEXT,
|
||||
ADD COLUMN "complimentaryDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
@@ -165,6 +165,7 @@ model User {
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
aiCalls AiCall[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -505,6 +506,15 @@ model Registration {
|
||||
bundleDiscount Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
// Corrections (1.52.0). The attendee form can come later: tickets don't wait for it ("Fill in
|
||||
// later" at the door or in Manual registrations, or an admin in Corrections).
|
||||
formWaivedAt DateTime?
|
||||
formWaivedById String?
|
||||
// An admin issued the tickets before it was paid ("pay later"): the balance is still owed.
|
||||
ticketsReleasedAt DateTime?
|
||||
ticketsReleasedById String?
|
||||
// A balance an admin wrote off ("make it free"); part of discountTotal (utils/coupons.js).
|
||||
complimentaryDiscount Float @default(0)
|
||||
analyticsSessionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -778,6 +788,10 @@ enum AdminAuditAction {
|
||||
event_sessions_changed
|
||||
attendance_marked
|
||||
attendance_unmarked
|
||||
attendee_form_waived
|
||||
tickets_issued_override
|
||||
registration_complimentary
|
||||
checkin_corrected
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
@@ -820,6 +834,7 @@ enum AdminAuditAction {
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
registrations_bulk
|
||||
ai_settings_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2315,3 +2330,21 @@ model SavedFilter {
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
/// One AI helper call (2.0). The Hub meters the money; this copy is for Site settings → AI (use
|
||||
/// per person, per helper and per day) and per-person daily limits. No prompt or answer text is
|
||||
/// kept. `userId` is cleared when the account is anonymised; rows go after 13 months.
|
||||
model AiCall {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
helper String
|
||||
// ok, refused, incomplete, failed, cap_reached, unavailable
|
||||
outcome String
|
||||
// What the church was charged, in cents (fractions of a cent).
|
||||
chargedCents Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The AI helpers' eval set (roadmap §15): `npm run ai:eval` in backend/.
|
||||
*
|
||||
* Runs fixed examples of all five helpers through this site's real Hub connection (so the site must
|
||||
* be connected and its licence must have an AI allowance), checks the facts that matter
|
||||
* automatically, and prints every answer for a person to read. Run it after changing a helper's
|
||||
* instructions or the model on the Hub, and compare with the last run. It costs about R1 a run
|
||||
* with Claude Sonnet 5.5; the calls count against this site's allowance.
|
||||
*
|
||||
* The examples come from the October 2026 model trial; the inputs are made up and hold no
|
||||
* personal information.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { prisma } from '../src/v2/legacy';
|
||||
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText } from '../src/ai/helpers';
|
||||
|
||||
type Check = [label: string, ok: boolean];
|
||||
type Case = { name: string; run: (userId: string) => Promise<{ output: unknown; checks: Check[] }> };
|
||||
|
||||
const has = (text: unknown, re: RegExp) => re.test(JSON.stringify(text));
|
||||
const near = (n: number, target: number, give = 1) => Math.abs(n - target) <= give;
|
||||
|
||||
const CAMP_NOTES = 'Youth camp for grades 8–12, Fri 14 March 18:00 to Sun 16 March 14:00 at Camp Wartburg near Pietermaritzburg. 60 places. R450 per person, R400 if they book by 31 January. Parents must give consent. Theme "Rooted" (Colossians 2:7). Costs: venue R180 per person per night including meals, bus hire R6 500 return, speaker gift R1 000, T-shirts R95 each. Friday evening worship, Saturday sessions morning and evening with sport in the afternoon, Sunday morning service before we leave. Bring a sleeping bag, Bible and a torch.';
|
||||
|
||||
const FEEDBACK_COMMENTS = [
|
||||
'The keynote on Saturday morning was exactly what I needed this year.', 'Could not hear a thing in Hall B, the sound kept cutting out.',
|
||||
'Lunch queue took 40 minutes, we missed the start of the afternoon session.', 'Loved the worship, so good to be together again.',
|
||||
'Breakouts were too short, just as it got deep we had to leave.', 'Check-in with the QR code was so quick, well done.',
|
||||
'Parking was a nightmare, we parked on the road and walked.', 'Please have childcare next year, I almost could not come.',
|
||||
'Speaker in the parenting breakout was brilliant.', 'Sound in Hall B again, the mic was crackling the whole time.',
|
||||
'Food ran out of vegetarian options by the time we got there.', 'The whole day felt so well organised.',
|
||||
'More time for breakouts please, and maybe fewer of them.', 'Lunch took forever.', 'Loved the gift bags!',
|
||||
'Hall A was freezing, the aircon was too cold.', 'The app reminders the day before were really helpful.',
|
||||
'Would love a longer Q&A with the main speaker.', 'Queue for lunch was very long, maybe stagger the breakouts?',
|
||||
'Parking marshals would help a lot.', 'Worship team was amazing.', 'Childcare or a kids programme would let young moms come.',
|
||||
'Some of the slides were hard to read from the back.', 'Best conference yet, thank you to the whole team.',
|
||||
'I would pay a bit more for a better lunch.', 'Hall B sound please fix it.',
|
||||
];
|
||||
|
||||
const CASES: Case[] = [
|
||||
{
|
||||
name: 'Draft an event — full notes with costs',
|
||||
async run(userId) {
|
||||
const r = await draftEvent(userId, CAMP_NOTES);
|
||||
const d = r.draft;
|
||||
return {
|
||||
output: r,
|
||||
checks: [
|
||||
['price R450, early bird R400 until 31 January', d.ticketOptions[0]?.priceRand === 450 && d.ticketOptions[0]?.earlyBirdPriceRand === 400 && d.ticketOptions[0]?.earlyBirdUntil.endsWith('-01-31')],
|
||||
['60 places, parental consent', d.capacity === 60 && d.needsParentalConsent],
|
||||
['budget R34 800, break-even 78', r.totalCostRand === 34800 && r.breakEvenPlaces === 78],
|
||||
['warns the camp is short even when full', r.warnings.some(w => w.includes('short'))],
|
||||
['times not in the notes left empty (only 18:00 and 14:00 given)', d.sessions.every(s => !s.startTime || s.startTime === '18:00')],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise a report — a month of payments',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseReport(userId, {
|
||||
reportKey: 'revenue', title: 'Revenue summary — October 2026 (September for comparison)',
|
||||
columns: ['Method', 'October payments', 'October amount', 'September payments', 'September amount'],
|
||||
rows: [['Card', 96, 'R42 300', 81, 'R35 100'], ['EFT', 14, 'R18 000', 17, 'R21 500'], ['Cash at the door', 31, 'R6 250', 22, 'R4 100'], ['Refunds', 4, 'R2 700', 2, 'R450']],
|
||||
stats: [{ label: 'Card payments waiting for the provider over 2 days', value: '3 (R1 350)' }],
|
||||
});
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['October total R66 550', has(summary, /66[\s ]?550/)],
|
||||
['up on September (R60 700)', has(summary, /60[\s ]?700|5[\s ]?850|9[.,]6/)],
|
||||
['the 3 waiting card payments need checking', has(summary.watchOut, /1[\s ]?350|3 (online |card )?payments/i)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise feedback — women\'s conference',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseFeedback(userId, {
|
||||
event: { title: 'Women\'s Conference 2026' }, responses: 112, invited: 186,
|
||||
questions: [
|
||||
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 }, { label: 'Venue', type: 'rating', answered: 110, average: 3.9 },
|
||||
{ label: 'Food', type: 'rating', answered: 108, average: 3.6 }, { label: 'Comments', type: 'text', answered: FEEDBACK_COMMENTS.length, texts: FEEDBACK_COMMENTS },
|
||||
],
|
||||
});
|
||||
// A theme may be split (e.g. "lunch queues" and "vegetarian food"): add up every match.
|
||||
const count = (re: RegExp) => summary.improve.filter(t => re.test(t.theme)).reduce((n, t) => n + t.mentions, 0);
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['lunch/food raised by about 5', near(count(/lunch|food|queue/i), 5)],
|
||||
['Hall B sound raised by 3', near(count(/sound|hall b/i), 3, 0)],
|
||||
['quotes copied exactly', summary.quotes.every(q => FEEDBACK_COMMENTS.includes(q))],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — WhatsApp reminder',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'whatsapp', note: 'Youth camp this weekend: remind them to bring a sleeping bag; the bus leaves at 7:30 on Friday from the church car park.' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['uses {{name}}', message.body.includes('{{name}}')],
|
||||
['keeps 7:30, sleeping bag, car park', /7:30/.test(message.body) && /sleeping bag/i.test(message.body) && /car park/i.test(message.body)],
|
||||
['no "reminda" (seen with Haiku)', !/reminda/i.test(message.body)],
|
||||
['no event placeholders without an event', !/\{\{event\./.test(message.body)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — email about a venue change',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'email', note: 'Men\'s breakfast this Saturday is moving from the church hall to the Protea Hotel on Main Road because the hall is being renovated. Same time, 7:00. Park at the back of the hotel. Price stays R120, nothing to do if you\'ve paid. Speaker is still Pastor Mike. Questions: events@church.example.org' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['has a subject', message.subject.trim().length > 5],
|
||||
['keeps the facts', /Protea/.test(message.body) && /7:00/.test(message.body) && /R120/.test(message.body)],
|
||||
['the email address came back intact', message.body.includes('events@church.example.org')],
|
||||
['no event placeholders without an event', !/\{\{event\./.test(`${message.subject} ${message.body}`)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Improve this text — a messy description',
|
||||
async run(userId) {
|
||||
const { result } = await improveText(userId, { text: 'join us for are anual family fun day!!! theres jumping castles, food stalls and a talent show for the kids. Bring the hole family its going to be awesome. tickets R50 for adults kids under 12 free. starts 9 till 2 on the church field, if it rains we move inside to the hall. please register so we know how many boerewors rolls to make lol', mode: 'improve', where: 'description' });
|
||||
return {
|
||||
output: result,
|
||||
checks: [
|
||||
['spelling fixed', /annual/i.test(result.text) && /whole family/i.test(result.text) && !/\bare anual\b/.test(result.text)],
|
||||
['every fact kept (R50, under 12, 9 to 2, hall)', /R50/.test(result.text) && /12/.test(result.text) && /\b9\b/.test(result.text) && /\b2\b/.test(result.text) && /hall/i.test(result.text)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const admin = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
if (!admin) throw new Error('No active admin to log the calls against.');
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
for (const c of CASES) {
|
||||
console.log(`\n━━ ${c.name}`);
|
||||
try {
|
||||
const { output, checks } = await c.run(admin.id);
|
||||
for (const [label, ok] of checks) {
|
||||
total++;
|
||||
if (ok) passed++;
|
||||
console.log(` ${ok ? '✓' : '✗'} ${label}`);
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
|
||||
} catch (e) {
|
||||
total++;
|
||||
console.log(` ✗ failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} of ${total} checks passed. Read the answers too: the checks only cover the facts.`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(passed === total ? 0 : 1);
|
||||
})().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { hashPassword } = require('../src/config/auth');
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
@@ -47,7 +47,7 @@ function randomPhone() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('Demo@1234', 10);
|
||||
const passwordHash = await hashPassword('Demo@1234');
|
||||
|
||||
// Role distribution: ~55 user, ~15 staff, ~5 supervisor
|
||||
const rolePool = [
|
||||
|
||||
@@ -42,7 +42,7 @@ const readline = require('readline');
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { hashPassword } = require('../src/config/auth');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const { applyBundles } = require('../src/utils/bundles');
|
||||
@@ -1859,7 +1859,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
passwordHash = await hashPassword(DEMO_PASSWORD);
|
||||
const people = buildPeople();
|
||||
if (profile.campuses) buildCampuses();
|
||||
const events = profile.upcoming.map(key => UPCOMING[key](people));
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* The five AI helpers (roadmap §15): each one's instructions, the shape of its answer, and what it
|
||||
* sends. Run through `runHelper` (./index.ts), which goes via the Hub.
|
||||
*
|
||||
* What leaves the site (POPIA): only what a helper needs, and never names, contact details,
|
||||
* medical or children's information.
|
||||
* - Email addresses, phone numbers and ID numbers in any free text are swapped for placeholders
|
||||
* like [[CONTACT1]] before sending, and put back in the answer (`protect` / `restore`).
|
||||
* - Reports: only those without people in them (REPORTS_WITHOUT_PEOPLE).
|
||||
* - Feedback: ratings, choice counts and comments, never who answered.
|
||||
*
|
||||
* Lessons from the October 2026 trial, built in: every helper is told today's date, and the checks
|
||||
* that matter most (a weekday that doesn't match its date, an event that loses money when full)
|
||||
* are worked out here in code as well, not left to the model.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { runHelper, type AiStatus } from './index';
|
||||
|
||||
// ─── Dates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAST_MS = 2 * 3600 * 1000;
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
/** "Today is Friday 30 October 2026." in South African time. */
|
||||
export function todayLine(now = new Date()): string {
|
||||
const d = new Date(now.getTime() + SAST_MS);
|
||||
return `Today is ${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}.`;
|
||||
}
|
||||
|
||||
// ─── Keeping contact details on the site ──────────────────────────────────────
|
||||
|
||||
const CONTACT_RE = [
|
||||
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // email
|
||||
/(?:\+27|\b0)(?:[\s-]?\d){9}\b/g, // South African phone numbers
|
||||
/\b\d{13}\b/g, // South African ID numbers
|
||||
];
|
||||
|
||||
export type Protected = { text: string; found: Map<string, string> };
|
||||
|
||||
/** Swap contact details for [[CONTACTn]], so they never leave the site. */
|
||||
export function protect(text: string, found: Map<string, string> = new Map()): Protected {
|
||||
let out = text;
|
||||
for (const re of CONTACT_RE) {
|
||||
out = out.replace(re, match => {
|
||||
for (const [k, v] of found) if (v === match) return k;
|
||||
const key = `[[CONTACT${found.size + 1}]]`;
|
||||
found.set(key, match);
|
||||
return key;
|
||||
});
|
||||
}
|
||||
return { text: out, found };
|
||||
}
|
||||
|
||||
/** Put the contact details back into an answer (strings anywhere inside it). */
|
||||
export function restore<T>(value: T, found: Map<string, string>): T {
|
||||
if (!found.size) return value;
|
||||
const fix = (s: string) => s.replace(/\[\[CONTACT\d+\]\]/g, k => found.get(k) ?? k);
|
||||
const walk = (v: unknown): unknown => (typeof v === 'string' ? fix(v) : Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
|
||||
return walk(value) as T;
|
||||
}
|
||||
|
||||
const KEEP_PLACEHOLDERS = 'Text like [[CONTACT1]] stands for a contact detail the site keeps private: copy it exactly where it belongs and never change it.';
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const str = { type: 'string' };
|
||||
const num = { type: 'number' };
|
||||
const int = { type: 'integer' };
|
||||
const bool = { type: 'boolean' };
|
||||
const list = (items: object) => ({ type: 'array', items });
|
||||
const obj = (properties: Record<string, object>, descriptions: Record<string, string> = {}) => ({
|
||||
type: 'object', additionalProperties: false, required: Object.keys(properties),
|
||||
properties: Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, descriptions[k] ? { ...v, description: descriptions[k] } : v])),
|
||||
});
|
||||
|
||||
// ─── 1. Draft an event ────────────────────────────────────────────────────────
|
||||
|
||||
export type EventDraftAnswer = {
|
||||
title: string; summary: string; description: string;
|
||||
startDate: string; startTime: string; endDate: string; endTime: string;
|
||||
location: string; capacity: number; needsParentalConsent: boolean; ageGroup: string;
|
||||
ticketOptions: { name: string; priceRand: number; description: string; earlyBirdPriceRand: number; earlyBirdUntil: string }[];
|
||||
sessions: { date: string; startTime: string; endTime: string; label: string }[];
|
||||
costs: { item: string; basis: string; amountRand: number }[];
|
||||
checks: string[];
|
||||
};
|
||||
|
||||
const EVENT_SCHEMA = obj({
|
||||
title: str, summary: str, description: str,
|
||||
startDate: str, startTime: str, endDate: str, endTime: str,
|
||||
location: str, capacity: int, needsParentalConsent: bool, ageGroup: str,
|
||||
ticketOptions: list(obj({ name: str, priceRand: num, description: str, earlyBirdPriceRand: num, earlyBirdUntil: str }, { earlyBirdPriceRand: '0 when there is none', earlyBirdUntil: 'YYYY-MM-DD, or empty' })),
|
||||
sessions: list(obj({ date: str, startTime: str, endTime: str, label: str })),
|
||||
costs: list(obj({ item: str, basis: str, amountRand: num }, { basis: 'How it is worked out, e.g. "R180 × 60 people × 2 nights"', amountRand: 'Total for this line at full capacity' })),
|
||||
checks: list(str),
|
||||
}, {
|
||||
summary: 'One sentence for the event card',
|
||||
description: 'The public event page: 2–4 short paragraphs, plain text',
|
||||
startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startTime: 'HH:MM, 24-hour, or empty', endTime: 'HH:MM, 24-hour, or empty',
|
||||
capacity: 'Places in total; 0 when not given',
|
||||
sessions: 'Only for an event that meets on several separate dates people attend, like a weekly course: one row per date. Empty for a single event, even one over a weekend.',
|
||||
checks: 'Things to look at before publishing: missing details, contradictions, money that does not add up. Empty if none.',
|
||||
});
|
||||
|
||||
const EVENT_SYSTEM = (now: Date) => [
|
||||
'You help a church events team set up an event in Cross Events, an event registration system. From the organiser\'s notes, fill in the event form.',
|
||||
todayLine(now),
|
||||
'- Title: short and clear. Description: for the public event page, warm and clear, 2–4 short paragraphs, plain text (no markdown): who it is for, what happens, what is included, what to bring if known.',
|
||||
'- South African rand; 24-hour times. Dates as YYYY-MM-DD: the next time that date comes round after today, unless the notes give a year.',
|
||||
'- If a weekday in the notes does not match its date, keep the date and say so in "checks".',
|
||||
'- Ticket options: the prices the notes give, with any early-bird price and its last day. One option when only one price is given.',
|
||||
'- Leave anything the notes do not give empty (or 0) rather than guess, including times.',
|
||||
'- Costs: one line per cost the notes give, worked out at full capacity, showing how. Never invent costs.',
|
||||
'- Never invent names, phone numbers, email addresses or links.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
/** Weekdays written next to dates in the notes ("Fri 14 March") that don't match the year used. */
|
||||
export function weekdayClashes(notes: string, year: number): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\.?,?\s+(\d{1,2})(?:st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*/gi;
|
||||
for (const m of notes.matchAll(re)) {
|
||||
const day = Number(m[2]);
|
||||
const month = MONTHS.findIndex(x => x.toLowerCase().startsWith(m[3].toLowerCase()));
|
||||
const actual = new Date(Date.UTC(year, month, day)).getUTCDay();
|
||||
const said = WEEKDAYS.findIndex(w => w.toLowerCase().startsWith(m[1].toLowerCase()));
|
||||
if (actual !== said) out.push(`The notes say ${WEEKDAYS[said]} ${day} ${MONTHS[month]}, but ${day} ${MONTHS[month]} ${year} is a ${WEEKDAYS[actual]}. Check the dates.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** R34 800 — plain spaces between thousands, as the AI writes them (en-ZA uses a no-break space). */
|
||||
const rands = (n: number) => `R${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
|
||||
|
||||
/** Money and date checks worked out in code, whatever the model said. */
|
||||
export function eventWarnings(notes: string, a: EventDraftAnswer): string[] {
|
||||
const out: string[] = [];
|
||||
const year = Number(a.startDate.slice(0, 4));
|
||||
if (year > 2000) out.push(...weekdayClashes(notes, year));
|
||||
if (a.startDate && a.endDate && `${a.endDate}T${a.endTime || '23:59'}` < `${a.startDate}T${a.startTime || '00:00'}`) out.push('The end is before the start.');
|
||||
const costs = a.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const main = a.ticketOptions[0];
|
||||
if (costs > 0 && main?.priceRand > 0) {
|
||||
const breakEven = Math.ceil(costs / main.priceRand);
|
||||
if (a.capacity > 0 && breakEven > a.capacity) {
|
||||
const full = a.capacity * main.priceRand;
|
||||
const early = main.earlyBirdPriceRand > 0 ? a.capacity * main.earlyBirdPriceRand : null;
|
||||
out.push(`The costs in the notes come to ${rands(costs)}, which needs ${breakEven} places at ${rands(main.priceRand)} — more than the ${a.capacity} available. Even full, the event takes ${rands(full)}${early ? ` (${rands(early)} if everyone books early)` : ''}, ${rands(costs - full)} short.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function draftEvent(userId: string, notes: string, now = new Date()): Promise<{ draft: EventDraftAnswer; warnings: string[]; totalCostRand: number; breakEvenPlaces: number | null; status: AiStatus }> {
|
||||
const p = protect(notes);
|
||||
const { output, status } = await runHelper('draft_event', userId, { system: EVENT_SYSTEM(now), input: p.text, schema: EVENT_SCHEMA, maxTokens: 4000, effort: 'low' });
|
||||
const draft = restore(output as EventDraftAnswer, p.found);
|
||||
const totalCostRand = draft.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const price = draft.ticketOptions[0]?.priceRand || 0;
|
||||
return { draft, warnings: eventWarnings(notes, draft), totalCostRand, breakEvenPlaces: totalCostRand > 0 && price > 0 ? Math.ceil(totalCostRand / price) : null, status };
|
||||
}
|
||||
|
||||
// ─── 2. Summarise a report ────────────────────────────────────────────────────
|
||||
|
||||
/** Reports with no people in them: only these can be summarised (keys from ReportCatalog). */
|
||||
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
|
||||
const MAX_ROWS = 400;
|
||||
|
||||
export type ReportInput = {
|
||||
reportKey: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
stats?: { label: string; value: string | number }[];
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
};
|
||||
|
||||
export type ReportSummary = { headline: string; insights: { title: string; detail: string }[]; watchOut: string[] };
|
||||
|
||||
const REPORT_SCHEMA = obj({ headline: str, insights: list(obj({ title: str, detail: str })), watchOut: list(str) }, { watchOut: 'Things that need someone to act; empty if none' });
|
||||
|
||||
const REPORT_SYSTEM = (now: Date) => [
|
||||
'You summarise a report for the leaders of a church that uses Cross Events, an event registration system. They are busy volunteers and staff, not analysts.',
|
||||
todayLine(now),
|
||||
'- A one-sentence headline, then 3–5 insights: trends, changes against earlier periods, outliers, and anything that needs action.',
|
||||
'- Use the report\'s numbers; work out percentages and differences where they help, and get them right. Never invent figures or causes; when you suggest a cause, say it is only a possibility.',
|
||||
'- Mind the dates against today: an event that has already happened can\'t still be promoted or paid for before it starts.',
|
||||
'- Plain, friendly South African English. Amounts in rand like R18 900.',
|
||||
].join('\n');
|
||||
|
||||
/** The report as compact text: stats, then the table as pipe-separated rows. */
|
||||
export function reportText(r: ReportInput): string {
|
||||
const lines = [`Report: ${r.title}`];
|
||||
if (r.subtitle) lines.push(r.subtitle);
|
||||
for (const s of r.stats || []) lines.push(`${s.label}: ${s.value}`);
|
||||
lines.push(`Columns: ${r.columns.join(' | ')}`);
|
||||
for (const row of r.rows.slice(0, MAX_ROWS)) lines.push(row.map(c => String(c ?? '').replace(/\s+/g, ' ')).join(' | '));
|
||||
if (r.rows.length > MAX_ROWS) lines.push(`(${r.rows.length - MAX_ROWS} more rows not shown)`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function summariseReport(userId: string, report: ReportInput, now = new Date()): Promise<{ summary: ReportSummary; status: AiStatus }> {
|
||||
if (!(REPORTS_WITHOUT_PEOPLE as readonly string[]).includes(report.reportKey)) {
|
||||
throw Object.assign(new ApiError('FORBIDDEN', 'This report lists people, so it can\'t be sent to the AI. Summaries work on totals-only reports.'), { reason: 'AI_REPORT_HAS_PEOPLE' });
|
||||
}
|
||||
const p = protect(reportText(report));
|
||||
const { output, status } = await runHelper('summarise_report', userId, { system: REPORT_SYSTEM(now), input: p.text, schema: REPORT_SCHEMA, maxTokens: 2000, effort: 'low' });
|
||||
return { summary: restore(output as ReportSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 3. Draft a message ───────────────────────────────────────────────────────
|
||||
|
||||
export type MessageAnswer = { subject: string; body: string };
|
||||
|
||||
const MESSAGE_SCHEMA = obj({ subject: str, body: str }, { subject: 'Empty for WhatsApp' });
|
||||
|
||||
const MESSAGE_SYSTEM = (channel: 'email' | 'whatsapp', now: Date, withEvent: boolean) => [
|
||||
'You write messages from a church events team to people registered for an event, from the organiser\'s short note.',
|
||||
todayLine(now),
|
||||
channel === 'whatsapp'
|
||||
? '- WhatsApp: plain text, at most 3 short sentences, at most one emoji. "subject" is empty.'
|
||||
: '- Email: a clear subject, then the body in short plain-text paragraphs, the most important thing first. Start with "Hi {{name}}," and end with "The events team".',
|
||||
withEvent
|
||||
? '- Use {{name}} for the person\'s first name. {{event.title}}, {{event.start}} and {{event.link}} are filled in with the event\'s details; use them only where the message needs them, and don\'t add a separate block of event details.'
|
||||
: '- Use {{name}} for the person\'s first name. Use no other {{…}} placeholders: name the event as the note does.',
|
||||
'- Use only the facts in the note and the event details; never invent times, places, prices, links or contact details, or add advice the note doesn\'t give.',
|
||||
'- Spell carefully: this goes to everyone registered.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
export type EventFacts = { title: string; start: string; location: string | null };
|
||||
|
||||
export async function draftMessage(userId: string, input: { channel: 'email' | 'whatsapp'; note: string; event?: EventFacts | null }, now = new Date()): Promise<{ message: MessageAnswer; status: AiStatus }> {
|
||||
const p = protect(input.note);
|
||||
const facts = input.event ? `\n\nEvent: ${input.event.title}, ${input.event.start}${input.event.location ? `, at ${input.event.location}` : ''}` : '';
|
||||
const { output, status } = await runHelper('draft_message', userId, {
|
||||
system: MESSAGE_SYSTEM(input.channel, now, !!input.event), input: `Note: ${p.text}${facts}`, schema: MESSAGE_SCHEMA, maxTokens: input.channel === 'whatsapp' ? 600 : 1500, effort: 'low',
|
||||
});
|
||||
const message = restore(output as MessageAnswer, p.found);
|
||||
return { message: input.channel === 'whatsapp' ? { subject: '', body: message.body } : message, status };
|
||||
}
|
||||
|
||||
// ─── 4. Summarise feedback ────────────────────────────────────────────────────
|
||||
|
||||
export type FeedbackQuestion = { label: string; type: string; answered: number; average?: number | null; counts?: { label: string; value: number }[]; texts?: string[] };
|
||||
export type FeedbackSummary = { overall: string; liked: { theme: string; mentions: number }[]; improve: { theme: string; mentions: number }[]; quotes: string[]; nextTime: string[] };
|
||||
|
||||
const FEEDBACK_SCHEMA = obj({
|
||||
overall: str, liked: list(obj({ theme: str, mentions: int })), improve: list(obj({ theme: str, mentions: int })), quotes: list(str), nextTime: list(str),
|
||||
}, { liked: 'Most-mentioned first', improve: 'Most-mentioned first', quotes: 'Up to 3 short comments, copied exactly' });
|
||||
|
||||
const FEEDBACK_SYSTEM = [
|
||||
'You summarise the feedback people gave after a church event, for the team that ran it.',
|
||||
'- "overall": two sentences on how it went, using the ratings.',
|
||||
'- "liked" and "improve": the main themes, most-mentioned first, each with how many comments raised it. Count carefully: a comment counts once per theme.',
|
||||
'- "quotes": up to 3 short comments that capture the feedback well, copied exactly.',
|
||||
'- "nextTime": 2–4 concrete suggestions for next time, based only on the feedback.',
|
||||
'- The comments are anonymous; never guess who wrote one.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
const MAX_COMMENTS = 400;
|
||||
const MAX_COMMENT = 600;
|
||||
|
||||
/** The feedback as text: each question with its average or counts, and the comments numbered. */
|
||||
export function feedbackText(event: { title: string }, responses: number, invited: number, questions: FeedbackQuestion[]): string {
|
||||
const lines = [`Event: ${event.title}`, `Responses: ${responses}${invited ? ` of ${invited} asked` : ''}`];
|
||||
let n = 0;
|
||||
for (const q of questions) {
|
||||
if (q.average != null) lines.push(`${q.label} — average ${q.average} (${q.answered} answers)`);
|
||||
else if (q.counts) lines.push(`${q.label} — ${q.counts.map(c => `${c.label}: ${c.value}`).join(', ')}`);
|
||||
else if (q.texts?.length) {
|
||||
lines.push(`${q.label} — comments:`);
|
||||
for (const t of q.texts) {
|
||||
if (n++ >= MAX_COMMENTS) break;
|
||||
lines.push(`${n}. ${t.replace(/\s+/g, ' ').slice(0, MAX_COMMENT)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function summariseFeedback(userId: string, input: { event: { title: string }; responses: number; invited: number; questions: FeedbackQuestion[] }): Promise<{ summary: FeedbackSummary; status: AiStatus }> {
|
||||
const p = protect(feedbackText(input.event, input.responses, input.invited, input.questions));
|
||||
const { output, status } = await runHelper('summarise_feedback', userId, { system: FEEDBACK_SYSTEM, input: p.text, schema: FEEDBACK_SCHEMA, maxTokens: 2000, effort: 'low' });
|
||||
return { summary: restore(output as FeedbackSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 5. Improve this text ─────────────────────────────────────────────────────
|
||||
|
||||
export const IMPROVE_MODES = { improve: 'Make it clear and friendly', shorter: 'Make it shorter', fix: 'Only fix spelling and grammar' } as const;
|
||||
export type ImproveMode = keyof typeof IMPROVE_MODES;
|
||||
export type ImproveAnswer = { text: string; changes: string[] };
|
||||
|
||||
const IMPROVE_SCHEMA = obj({ text: str, changes: list(str) }, { changes: 'A short list of what changed and why' });
|
||||
|
||||
const IMPROVE_SYSTEM = (mode: ImproveMode, where: string) => [
|
||||
`You improve text a church events team wrote for ${where}.`,
|
||||
mode === 'fix'
|
||||
? '- Only fix spelling, grammar and punctuation. Change nothing else.'
|
||||
: mode === 'shorter'
|
||||
? '- Make it noticeably shorter and easier to scan, fixing spelling and grammar too.'
|
||||
: '- Fix spelling, grammar and punctuation, and make it clear and friendly, about the same length or shorter.',
|
||||
'- Keep every fact (times, dates, prices, places, ages) exactly; add none.',
|
||||
'- Plain text, no markdown. Keep the writer\'s warmth, and any humour that works in public. Keep placeholders like {{name}} exactly.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
const WHERE = { description: 'an event page', banner: 'a short banner across the top of the website', message: 'a message to people registered for an event' } as const;
|
||||
export type ImproveWhere = keyof typeof WHERE;
|
||||
|
||||
export async function improveText(userId: string, input: { text: string; mode: ImproveMode; where: ImproveWhere }): Promise<{ result: ImproveAnswer; status: AiStatus }> {
|
||||
const p = protect(input.text);
|
||||
const { output, status } = await runHelper('improve_text', userId, { system: IMPROVE_SYSTEM(input.mode, WHERE[input.where]), input: p.text, schema: IMPROVE_SCHEMA, maxTokens: Math.min(8000, 600 + Math.ceil(input.text.length / 2)), effort: 'low' });
|
||||
return { result: restore(output as ImproveAnswer, p.found), status };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* AI helpers (2.0, roadmap §15). The site never holds a Claude API key: every call goes through
|
||||
* the Cross Events Hub (docs/hub-protocol.md §3b), which checks the licence's monthly cap in rand,
|
||||
* calls Claude and meters the call. This module:
|
||||
* - works out what the AI buttons do (`aiStatus`): hidden, working, or disabled until the
|
||||
* allowance renews — from the licence token first, then the Hub (cached for a minute);
|
||||
* - runs one helper call (`runHelper`), with this site's own switches per helper and an optional
|
||||
* daily limit per person, and logs it in `AiCall` (no prompt or answer text);
|
||||
* - emails the site admins once a month when 80% of the allowance is used.
|
||||
* The helpers themselves (prompts, schemas, what they send) are in ./helpers.ts.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { prisma } from '../v2/legacy';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS Hub client and helpers aren't typed */
|
||||
const hub = require('../hub/client') as {
|
||||
PROTOCOL_VERSION: number;
|
||||
signedPost(path: string, body: object, opts?: { timeoutMs?: number }): Promise<any>;
|
||||
HubError: new (...args: any[]) => Error & { code: string; statusCode: number; data: any };
|
||||
};
|
||||
const { licenceStatus } = require('../hub/licence') as {
|
||||
licenceStatus(): Promise<{ connected: boolean; claims: { limits?: { aiMonthlyCapCents?: number } } | null; state: string; readOnly: boolean }>;
|
||||
};
|
||||
const email = require('../utils/email') as {
|
||||
sendMail(m: { to: string; subject: string; html: string; text: string }): Promise<unknown>;
|
||||
emailWrapper(body: string): string;
|
||||
isSmtpConfigured(): boolean;
|
||||
};
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<unknown> };
|
||||
|
||||
export const HELPERS = ['draft_event', 'summarise_report', 'draft_message', 'summarise_feedback', 'improve_text'] as const;
|
||||
export type HelperName = (typeof HELPERS)[number];
|
||||
|
||||
/** What the Hub says (§3b). Cents are charged rand, with fractions. */
|
||||
export type HubAiStatus = { enabled: boolean; capReached: boolean; capCents: number; usedCents: number; usedPercent: number; renewsOn: string | null };
|
||||
/** What the buttons need: the Hub's answer, whether it could be reached, and this site's switches. */
|
||||
export type AiStatus = HubAiStatus & { available: boolean; helpers: Record<HelperName, boolean> };
|
||||
|
||||
export type AiSettings = { helpers: Record<HelperName, boolean>; dailyLimitPerPerson: number };
|
||||
const SETTINGS_KEY = 'ai_settings';
|
||||
const WARNED_KEY = 'ai_warned_month';
|
||||
const STATUS_TTL_MS = 60 * 1000;
|
||||
const CALL_TIMEOUT_MS = 90 * 1000;
|
||||
const KEEP_CALLS_DAYS = 400;
|
||||
|
||||
const OFF: HubAiStatus = { enabled: false, capReached: false, capCents: 0, usedCents: 0, usedPercent: 0, renewsOn: null };
|
||||
|
||||
// ─── This site's settings ─────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SETTINGS: AiSettings = { helpers: Object.fromEntries(HELPERS.map(h => [h, true])) as Record<HelperName, boolean>, dailyLimitPerPerson: 0 };
|
||||
|
||||
export async function getAiSettings(): Promise<AiSettings> {
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||
let saved: Partial<AiSettings> = {};
|
||||
try { saved = row ? JSON.parse(row.value) : {}; } catch { /* keep the defaults */ }
|
||||
return {
|
||||
helpers: { ...DEFAULT_SETTINGS.helpers, ...Object.fromEntries(HELPERS.filter(h => typeof saved.helpers?.[h] === 'boolean').map(h => [h, saved.helpers![h]])) },
|
||||
dailyLimitPerPerson: Number.isInteger(saved.dailyLimitPerPerson) && saved.dailyLimitPerPerson! >= 0 ? saved.dailyLimitPerPerson! : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAiSettings(next: { helpers?: Partial<Record<HelperName, boolean | undefined>>; dailyLimitPerPerson?: number }, actor: { id: string; role: string }, ip?: string): Promise<AiSettings> {
|
||||
const current = await getAiSettings();
|
||||
const changed = Object.fromEntries(HELPERS.filter(h => typeof next.helpers?.[h] === 'boolean').map(h => [h, next.helpers![h]]));
|
||||
const value: AiSettings = {
|
||||
helpers: { ...current.helpers, ...changed },
|
||||
dailyLimitPerPerson: next.dailyLimitPerPerson ?? current.dailyLimitPerPerson,
|
||||
};
|
||||
await prisma.appSetting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(value) }, create: { key: SETTINGS_KEY, value: JSON.stringify(value) } });
|
||||
await logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'ai_settings_changed', targetType: 'setting', targetId: SETTINGS_KEY, metadata: value, ip });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let cached: { at: number; value: HubAiStatus; available: boolean } | null = null;
|
||||
|
||||
/** Keep what the Hub just said (every AI reply and error carries it). */
|
||||
function remember(value: unknown, available = true): void {
|
||||
const v = value as Partial<HubAiStatus> | null;
|
||||
if (v && typeof v.enabled === 'boolean') cached = { at: Date.now(), value: { ...OFF, ...v } as HubAiStatus, available };
|
||||
}
|
||||
|
||||
export function clearAiStatusCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap from the licence token (refreshed by every heartbeat): no Hub call is needed to know AI
|
||||
* is off, which is most sites.
|
||||
*/
|
||||
async function licenceCap(): Promise<number> {
|
||||
const l = await licenceStatus();
|
||||
if (!l.connected || !l.claims || l.readOnly) return 0;
|
||||
return Number(l.claims.limits?.aiMonthlyCapCents) || 0;
|
||||
}
|
||||
|
||||
async function hubStatus(fresh = false): Promise<{ value: HubAiStatus; available: boolean }> {
|
||||
if (!fresh && cached && Date.now() - cached.at < STATUS_TTL_MS) return cached;
|
||||
try {
|
||||
remember(await hub.signedPost('/api/installs/ai/status', { protocolVersion: hub.PROTOCOL_VERSION }));
|
||||
} catch (e) {
|
||||
const err = e as { code?: string; statusCode?: number };
|
||||
// A Hub before 1.6.0 has no AI; a released install has no licence: both mean off.
|
||||
if (err.statusCode === 404) cached = { at: Date.now(), value: OFF, available: true };
|
||||
else {
|
||||
console.warn('[ai] could not ask the Hub for the AI status:', (e as Error).message);
|
||||
// Unreachable: keep showing the buttons (they say AI is unavailable when pressed).
|
||||
cached = { at: Date.now(), value: { ...(cached?.value || OFF), enabled: true }, available: false };
|
||||
}
|
||||
}
|
||||
return cached!;
|
||||
}
|
||||
|
||||
export async function aiStatus({ fresh = false } = {}): Promise<AiStatus> {
|
||||
const settings = await getAiSettings();
|
||||
if ((await licenceCap()) <= 0) return { ...OFF, available: true, helpers: settings.helpers };
|
||||
const { value, available } = await hubStatus(fresh);
|
||||
return { ...value, available, helpers: settings.helpers };
|
||||
}
|
||||
|
||||
// ─── Running a helper ─────────────────────────────────────────────────────────
|
||||
|
||||
export type HelperRequest = {
|
||||
/** The helper's fixed instructions (the Hub has Claude cache them). */
|
||||
system: string;
|
||||
/** This request's text. Never names, contact details, medical or children's information. */
|
||||
input: string;
|
||||
/** A JSON schema: the answer comes back as JSON in this shape. */
|
||||
schema?: object;
|
||||
maxTokens: number;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
const failure = (code: 'FORBIDDEN' | 'RATE_LIMITED' | 'UNAVAILABLE', reason: string, message: string) => Object.assign(new ApiError(code, message), { reason });
|
||||
|
||||
function renewsText(renewsOn: string | null): string {
|
||||
if (!renewsOn) return 'next month';
|
||||
return new Date(`${renewsOn}T12:00:00Z`).toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', timeZone: 'Africa/Johannesburg' });
|
||||
}
|
||||
|
||||
/** The Hub's errors (§3b) as v2 errors, and what to log. */
|
||||
function fromHubError(e: { code?: string; statusCode?: number; message: string; data?: any }): { error: ApiError; outcome: string } {
|
||||
remember(e.data?.status);
|
||||
switch (e.code) {
|
||||
case 'AI_CAP_REACHED':
|
||||
return { outcome: 'cap_reached', error: failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(e.data?.status?.renewsOn ?? null)}.`) };
|
||||
case 'AI_OFF': case 'LICENCE_INACTIVE': case 'AI_NOT_CONFIGURED':
|
||||
return { outcome: 'unavailable', error: failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.') };
|
||||
case 'AI_REFUSED':
|
||||
return { outcome: 'refused', error: failure('UNAVAILABLE', 'AI_REFUSED', "The AI couldn't help with this one. Try wording it differently.") };
|
||||
case 'AI_INCOMPLETE':
|
||||
return { outcome: 'incomplete', error: failure('UNAVAILABLE', 'AI_INCOMPLETE', 'The answer was too long and got cut off. Try asking for less.') };
|
||||
case 'AI_INPUT_TOO_LARGE':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_INPUT_TOO_LARGE', 'There is too much here to send in one go.') };
|
||||
case 'AI_FAILED':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_FAILED', "The AI's answer couldn't be used. Try again.") };
|
||||
default:
|
||||
return { outcome: 'unavailable', error: failure('UNAVAILABLE', 'AI_UNAVAILABLE', 'AI is unavailable right now. Try again in a few minutes.') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts of today in South African time, for the per-person daily limit. */
|
||||
function startOfToday(now = new Date()): Date {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
return new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), sast.getUTCDate()) - 2 * 3600 * 1000);
|
||||
}
|
||||
|
||||
let prunedOn = '';
|
||||
/** Calls older than about 13 months go (once a day, on the first call). */
|
||||
async function pruneOldCalls(now = new Date()): Promise<void> {
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
if (prunedOn === today) return;
|
||||
prunedOn = today;
|
||||
await prisma.aiCall.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_CALLS_DAYS * 86400000) } } }).catch(() => {});
|
||||
}
|
||||
|
||||
/** At 80% of the allowance, the site's admins get one email a month. */
|
||||
async function warnAt80(status: HubAiStatus): Promise<void> {
|
||||
if (!status.enabled || status.usedPercent < 80 || !status.renewsOn) return;
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: WARNED_KEY } });
|
||||
if (row?.value === status.renewsOn) return;
|
||||
await prisma.appSetting.upsert({ where: { key: WARNED_KEY }, update: { value: status.renewsOn }, create: { key: WARNED_KEY, value: status.renewsOn } });
|
||||
if (!email.isSmtpConfigured()) return;
|
||||
const admins = await prisma.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
|
||||
const when = renewsText(status.renewsOn);
|
||||
const text = `This site has used ${status.usedPercent}% of this month's AI allowance (R${(status.usedCents / 100).toFixed(2)} of R${(status.capCents / 100).toFixed(2)}). The AI buttons stop working when it runs out, until it renews on ${when}. Site settings → AI shows who used it and for what.`;
|
||||
for (const a of admins) {
|
||||
await email.sendMail({ to: a.email, subject: `AI allowance ${status.usedPercent}% used`, text, html: email.emailWrapper(`<p>${text}</p>`) })
|
||||
.catch(e => console.warn('[ai] 80% warning email failed:', (e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one helper call for a person. Resolves to the answer (text, or JSON when a schema was
|
||||
* given) and the new status; throws an ApiError with a `reason` (AI_OFF, AI_HELPER_OFF,
|
||||
* AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE…).
|
||||
*/
|
||||
export async function runHelper(helper: HelperName, userId: string, req: HelperRequest): Promise<{ output: unknown; status: AiStatus }> {
|
||||
const status = await aiStatus();
|
||||
if (!status.enabled) throw failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.');
|
||||
if (!status.helpers[helper]) throw failure('FORBIDDEN', 'AI_HELPER_OFF', 'This AI helper is switched off in Site settings → AI.');
|
||||
if (status.capReached) throw failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(status.renewsOn)}.`);
|
||||
const { dailyLimitPerPerson } = await getAiSettings();
|
||||
if (dailyLimitPerPerson > 0) {
|
||||
const today = await prisma.aiCall.count({ where: { userId, createdAt: { gte: startOfToday() }, outcome: { in: ['ok', 'refused', 'incomplete'] } } });
|
||||
if (today >= dailyLimitPerPerson) throw failure('RATE_LIMITED', 'AI_DAILY_LIMIT', `You've used the AI helpers ${dailyLimitPerPerson} times today, the most this site allows. Try again tomorrow.`);
|
||||
}
|
||||
|
||||
let outcome = 'ok';
|
||||
let chargedCents = 0;
|
||||
try {
|
||||
const reply = await hub.signedPost('/api/installs/ai/complete', { protocolVersion: hub.PROTOCOL_VERSION, helper, ...req }, { timeoutMs: CALL_TIMEOUT_MS });
|
||||
chargedCents = Number(reply.chargedCents) || 0;
|
||||
remember(reply.status);
|
||||
await warnAt80(reply.status).catch(e => console.warn('[ai] 80% check failed:', (e as Error).message));
|
||||
return { output: reply.output, status: { ...(cached?.value || OFF), available: true, helpers: status.helpers } };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e;
|
||||
const mapped = fromHubError(e as Error & { code?: string; data?: any });
|
||||
outcome = mapped.outcome;
|
||||
if ((e as { code?: string }).code === 'HUB_UNREACHABLE') remember(cached?.value || { ...OFF, enabled: true }, false);
|
||||
throw mapped.error;
|
||||
} finally {
|
||||
await prisma.aiCall.create({ data: { userId, helper, outcome, chargedCents } }).catch(err => console.warn('[ai] could not log the call:', (err as Error).message));
|
||||
await pruneOldCalls();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Usage (Site settings → AI) ───────────────────────────────────────────────
|
||||
|
||||
/** This month's use on this site: per person, per helper and per day (South African time). */
|
||||
export async function usageThisMonth(now = new Date()) {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
const from = new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), 1) - 2 * 3600 * 1000);
|
||||
const calls = await prisma.aiCall.findMany({ where: { createdAt: { gte: from } }, select: { userId: true, helper: true, outcome: true, chargedCents: true, createdAt: true } });
|
||||
const add = (m: Map<string, { uses: number; chargedCents: number }>, key: string, cents: number) => {
|
||||
const v = m.get(key) || { uses: 0, chargedCents: 0 };
|
||||
v.uses++;
|
||||
v.chargedCents += cents;
|
||||
m.set(key, v);
|
||||
};
|
||||
const byPerson = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byHelper = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byDay = new Map<string, { uses: number; chargedCents: number }>();
|
||||
for (const c of calls) {
|
||||
if (c.outcome === 'cap_reached' || c.outcome === 'unavailable') continue;
|
||||
add(byPerson, c.userId || '', c.chargedCents);
|
||||
add(byHelper, c.helper, c.chargedCents);
|
||||
add(byDay, new Date(c.createdAt.getTime() + 2 * 3600 * 1000).toISOString().slice(0, 10), c.chargedCents);
|
||||
}
|
||||
const people = await prisma.user.findMany({ where: { id: { in: [...byPerson.keys()].filter(Boolean) } }, select: { id: true, name: true } });
|
||||
const names = new Map(people.map(p => [p.id, p.name]));
|
||||
return {
|
||||
people: [...byPerson.entries()].map(([id, v]) => ({ userId: id || null, name: id ? names.get(id) || 'Unknown' : 'Removed account', ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
helpers: [...byHelper.entries()].map(([helper, v]) => ({ helper, ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
days: [...byDay.entries()].map(([day, v]) => ({ day, ...v })).sort((a, b) => a.day.localeCompare(b.day)),
|
||||
};
|
||||
}
|
||||
|
||||
// For tests.
|
||||
export const _internal = { fromHubError, startOfToday, renewsText };
|
||||
+27
-1373
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const argon2 = require('@node-rs/argon2');
|
||||
// Only for checking passwords saved before 2.0 (they're re-hashed with Argon2id at the next sign-in).
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
|
||||
@@ -10,17 +12,30 @@ const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const hashPassword = async (password) => {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return await bcrypt.hash(password, salt);
|
||||
// Argon2id with OWASP's recommended minimum (19 MiB, 2 passes, 1 lane): about 20–40 ms a hash.
|
||||
const ARGON2 = { algorithm: 2 /* Argon2id (a const enum in the typings, so not on the module) */, memoryCost: 19456, timeCost: 2, parallelism: 1 };
|
||||
const ARGON2_PREFIX = `$argon2id$v=19$m=${ARGON2.memoryCost},t=${ARGON2.timeCost},p=${ARGON2.parallelism}$`;
|
||||
|
||||
const hashPassword = async (password) => argon2.hash(password, ARGON2);
|
||||
|
||||
/** Whether `entered` is the password behind `stored` (an Argon2 hash, or a bcrypt one from before 2.0). */
|
||||
const comparePassword = async (enteredPassword, storedPassword) => {
|
||||
if (typeof enteredPassword !== 'string' || typeof storedPassword !== 'string') return false;
|
||||
try {
|
||||
if (storedPassword.startsWith('$argon2')) return await argon2.verify(storedPassword, enteredPassword);
|
||||
if (/^\$2[aby]\$/.test(storedPassword)) return await bcrypt.compare(enteredPassword, storedPassword);
|
||||
} catch {
|
||||
// A malformed hash never matches.
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const comparePassword = async (enteredPassword, storedPassword) => {
|
||||
return await bcrypt.compare(enteredPassword, storedPassword);
|
||||
};
|
||||
/** True for a hash that isn't Argon2id with today's settings (bcrypt, or weaker Argon2). */
|
||||
const needsRehash = (storedPassword) => typeof storedPassword === 'string' && !storedPassword.startsWith(ARGON2_PREFIX);
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
};
|
||||
needsRehash,
|
||||
};
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
@@ -238,7 +238,9 @@ const createPayment = async (req, res) => {
|
||||
// If it's a registration payment, update registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
|
||||
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
|
||||
let formsNeeded = 0;
|
||||
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
@@ -284,6 +286,7 @@ const createPayment = async (req, res) => {
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
}
|
||||
formsNeeded = await formsOutstanding(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +321,7 @@ const createPayment = async (req, res) => {
|
||||
...updatedRegistration
|
||||
},
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
|
||||
formsNeeded,
|
||||
donationSplit: donationSplit || undefined
|
||||
};
|
||||
res.status(201).json(paymentWithUpdatedRegistration);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,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 };
|
||||
|
||||
+25
-27
@@ -10,34 +10,32 @@
|
||||
|
||||
const { licenceStatus } = require('./licence');
|
||||
|
||||
// /api/v2 only: 2.0 removed v1 (1.52.3 added these — the screens had used v2 since 1.51.15).
|
||||
const BLOCKED = [
|
||||
['POST', /^\/api\/events\/?$/],
|
||||
['PUT', /^\/api\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
|
||||
['POST', /^\/api\/series\/?$/],
|
||||
['PUT', /^\/api\/series\/[^/]+\/?$/],
|
||||
// Season passes: new or changed passes, and buying one (it makes registrations and payments).
|
||||
['POST', /^\/api\/season-passes\/?$/],
|
||||
['PUT', /^\/api\/season-passes\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/season-passes\/[^/]+\/buy\/?$/],
|
||||
['POST', /^\/api\/season-passes\/purchases\/[^/]+\/checkout\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
|
||||
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
|
||||
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/registrations\/?$/],
|
||||
['POST', /^\/api\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
|
||||
['POST', /^\/api\/payments\/?$/],
|
||||
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
|
||||
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/?$/],
|
||||
['PATCH', /^\/api\/v2\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/copy-from\/?$/],
|
||||
['POST', /^\/api\/v2\/series\/?$/],
|
||||
['PATCH', /^\/api\/v2\/series\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/season-passes\/?$/],
|
||||
['PATCH', /^\/api\/v2\/season-passes\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/season-passes\/[^/]+\/buy\/?$/],
|
||||
['POST', /^\/api\/v2\/season-pass-purchases\/[^/]+\/checkout\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/ticket-options\/?$/],
|
||||
['PATCH', /^\/api\/v2\/ticket-options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/ticket-options\/[^/]+\/variants\/?$/],
|
||||
['PATCH', /^\/api\/v2\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/v2\/registrations\/[^/]+\/items\/?$/],
|
||||
['POST', /^\/api\/v2\/payments\/?$/],
|
||||
['POST', /^\/api\/v2\/registrations\/[^/]+\/(checkout|payment-link(\/send)?)\/?$/],
|
||||
['POST', /^\/api\/v2\/payment-links\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/v2\/waitlist\/?$/],
|
||||
['POST', /^\/api\/v2\/coupons\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/bundles\/?$/],
|
||||
['POST', /^\/api\/v2\/volunteer-roles\/[^/]+\/(sign-up|assign)\/?$/],
|
||||
['POST', /^\/api\/v2\/events\/[^/]+\/volunteer-roles\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
|
||||
const { listJobs, runJobNow } = require('../controllers/jobsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
router.get('/jobs', protect, admin, listJobs);
|
||||
router.post('/jobs/:name/run', protect, admin, runJobNow);
|
||||
router.get('/impersonation-sessions', protect, admin, listSessions);
|
||||
// Called with the impersonation token itself (which belongs to the viewed user).
|
||||
router.post('/impersonate/end', protect, endImpersonation);
|
||||
router.post('/impersonate/log', protect, logPageView);
|
||||
router.get('/impersonate/current', protect, currentImpersonation);
|
||||
router.post('/impersonate/:userId', protect, admin, startImpersonation);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,23 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/my-groups', protect, staff, campusIndependent('only the groups the signed-in person leads'), getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getBoard);
|
||||
router.post('/sets', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createSet);
|
||||
router.put('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,19 +0,0 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => res.status(204).end(), // silently drop floods
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,26 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getRegister, markAttendance, exportRegister,
|
||||
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
} = require('../controllers/attendanceController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Certificates — fixed paths first so they aren't taken as an :eventId
|
||||
router.get('/certificates/verify/:code', verifyCertificate);
|
||||
router.get('/certificates/mine', protect, myCertificates);
|
||||
router.post('/certificates/preview', protect, supervisor, campusIndependent('previews a certificate template from the request body'), previewCertificate);
|
||||
router.get('/certificates/:id/pdf', protect, downloadCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
|
||||
|
||||
// Attendance register
|
||||
router.get('/:eventId', protect, staff, scopeEvent(), getRegister);
|
||||
router.get('/:eventId/export', protect, staff, scopeEvent(), exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, scopeEvent(), markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, scopeEvent(), issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, scopeEvent(), sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, scopeEvent(), downloadEventCertificates);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, admin, getBackups);
|
||||
router.post('/run', protect, admin, triggerBackup);
|
||||
router.get('/:filename/download', protect, admin, downloadBackup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, unscopedOnly, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), previewBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,15 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), createBundle);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), updateBundle);
|
||||
router.delete('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,21 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus,
|
||||
getCampusAudience,
|
||||
} = require('../controllers/campusController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Multi-campus (utils/campuses.js). The public list is empty while campuses are off.
|
||||
router.get('/', getCampuses);
|
||||
router.get('/by-slug/:slug', getCampusBySlug);
|
||||
router.get('/admin', protect, admin, getCampusesAdmin);
|
||||
router.put('/settings', protect, admin, updateCampusSettings);
|
||||
router.put('/order', protect, admin, reorderCampuses);
|
||||
router.post('/', protect, admin, createCampus);
|
||||
router.put('/:id', protect, admin, updateCampus);
|
||||
router.delete('/:id', protect, admin, deleteCampus);
|
||||
router.get('/:id/audience', protect, supervisor, scopeList, getCampusAudience);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,16 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/audit', protect, supervisor, scopeList, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, scopeEvent({ need: 'manage' }), getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, scopeEvent({ need: 'manage' }), savePersonCash);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,21 +0,0 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
|
||||
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
router.post('/:token', consentLimiter, answerConsent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,13 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,16 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, scopeList, listCoupons);
|
||||
router.post('/', protect, supervisor, scopeList, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, scopeList, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, scopeList, getCoupon);
|
||||
router.put('/:id', protect, supervisor, scopeList, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,93 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
deleteEvent,
|
||||
createEventOption,
|
||||
updateEventOption,
|
||||
deleteEventOption,
|
||||
createOptionVariant,
|
||||
updateOptionVariant,
|
||||
deleteOptionVariant,
|
||||
listEventAttachments,
|
||||
uploadEventAttachment,
|
||||
deleteEventAttachment,
|
||||
attachmentsStatus,
|
||||
attachmentsSync,
|
||||
emailEventAttendees,
|
||||
scheduleEmailEventAttendees,
|
||||
whatsappEventAttendees,
|
||||
scheduleWhatsappEventAttendees,
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
|
||||
// Staff/supervisor/admin: all events including hidden, optional past/inactive filters
|
||||
router.get('/all', protect, getEventsAll);
|
||||
|
||||
// Admin routes
|
||||
router.get('/admin/all', protect, admin, getAllEvents);
|
||||
router.get('/attachments/status', protect, admin, attachmentsStatus);
|
||||
router.post('/attachments/sync', protect, admin, attachmentsSync);
|
||||
|
||||
// Public route for single event (keep after specific admin routes).
|
||||
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
|
||||
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||
router.get('/:id', optionalAuth, getEventById);
|
||||
router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, scopeList, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Clone: copy allocation groups (and costs, for admins) from the source event into a new copy.
|
||||
router.post('/:id/copy-from', protect, supervisor, scopeEvent({ need: 'manage' }), scopeBody([['fromEventId', 'event']]), copyEventSetupFrom);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,15 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, scopeList, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, scopeEvent({ need: 'manage' }), sendNow);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, scopeList, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
|
||||
// admin token counts, so the licence step can run during /setup.
|
||||
router.get('/status', protect, admin, getStatus);
|
||||
router.post('/connect', protect, admin, connect);
|
||||
router.post('/heartbeat', protect, admin, heartbeatNow);
|
||||
router.post('/update-request', protect, admin, requestUpdate);
|
||||
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
|
||||
router.post('/update-snooze', protect, admin, snoozeUpdate);
|
||||
// The provider's notices: site admins and supervisors see them as a banner.
|
||||
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,47 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/kidsController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Kids (any signed-in parent; only their own family)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.post('/me', protect, c.createMine);
|
||||
router.put('/me', protect, c.renameMine);
|
||||
router.delete('/me', protect, c.leaveMine);
|
||||
router.post('/me/children', protect, c.saveMyChild);
|
||||
router.put('/me/children/:id', protect, c.saveMyChild);
|
||||
router.delete('/me/children/:id', protect, c.deleteMyChild);
|
||||
router.post('/me/adults', protect, c.saveMyAdult);
|
||||
router.put('/me/adults/:id', protect, c.saveMyAdult);
|
||||
router.delete('/me/adults/:id', protect, c.deleteMyAdult);
|
||||
|
||||
// Setup (supervisors)
|
||||
router.get('/setup-events', protect, supervisor, scopeList, c.setupEvents);
|
||||
router.get('/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
|
||||
// The desk and the rooms (staff). Families are church-wide; staff reach them through an event
|
||||
// they can work on.
|
||||
router.get('/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/events/:eventId/board', protect, staff, scopeEvent(), c.getBoard);
|
||||
router.get('/events/:eventId/families', protect, staff, scopeEvent(), c.searchFamilies);
|
||||
router.post('/events/:eventId/families', protect, staff, scopeEvent(), c.createFamilyAtDesk);
|
||||
router.get('/events/:eventId/families/qr/:token', protect, staff, scopeEvent(), c.familyByQr);
|
||||
router.get('/events/:eventId/families/:familyId', protect, staff, scopeEvent(), c.getFamily);
|
||||
router.post('/events/:eventId/families/:familyId/parents', protect, staff, scopeEvent(), c.addParentAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/children', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.put('/events/:eventId/families/:familyId/children/:childId', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/adults', protect, staff, scopeEvent(), c.addAdultAtDesk);
|
||||
router.post('/events/:eventId/check-in', protect, staff, scopeEvent(), c.checkIn);
|
||||
router.post('/events/:eventId/check-out', protect, staff, scopeEvent(), c.checkOut);
|
||||
router.get('/events/:eventId/code/:code', protect, staff, scopeEvent(), c.lookupCode);
|
||||
router.post('/events/:eventId/duty', protect, staff, scopeEvent(), c.setDuty);
|
||||
router.get('/events/:eventId/offline', protect, staff, scopeEvent(), c.offlineSnapshot);
|
||||
router.post('/events/:eventId/sync', protect, staff, scopeEvent(), c.syncOffline);
|
||||
router.post('/check-ins/:id/override', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.overrideCheckOut);
|
||||
router.post('/check-ins/:id/page', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.pageParent);
|
||||
router.put('/check-ins/:id/room', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.moveRoom);
|
||||
router.post('/alerts/:id/resolve', protect, staff, scopeEvent({ resolve: 'kidsAlert' }), c.resolveAlert);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,17 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { list, create, update, remove, rotateSecret, test, deliveries, resend } = require('../controllers/outboundWebhookController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Outbound webhooks (1.48.0): admins only. Payment provider webhooks *into* the site are /api/webhooks/<provider>.
|
||||
router.use(protect, admin);
|
||||
router.get('/', list);
|
||||
router.post('/', create);
|
||||
router.post('/deliveries/:id/resend', resend);
|
||||
router.put('/:id', update);
|
||||
router.delete('/:id', remove);
|
||||
router.post('/:id/rotate-secret', rotateSecret);
|
||||
router.post('/:id/test', test);
|
||||
router.get('/:id/deliveries', deliveries);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.post('/test', protect, admin, testConnection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,54 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createPayment,
|
||||
getPayments,
|
||||
getUserPayments,
|
||||
getPaymentById,
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
|
||||
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
|
||||
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
|
||||
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, paymentTarget, createPayment);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
router.post('/pay/:id', choosePayProvider);
|
||||
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
|
||||
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
// Older names, kept for existing clients
|
||||
router.post('/yoco-checkout', protect, createCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, scopeList, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,87 +0,0 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getMyPrivacy,
|
||||
updateMyNews,
|
||||
exportMyData,
|
||||
createMyRequest,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
oneClickUnsubscribe,
|
||||
oneClickRedirect,
|
||||
listRequests,
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
} = require('../controllers/privacyController');
|
||||
const {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
markBuiltInReviewed,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
|
||||
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// The person themselves
|
||||
router.get('/me', protect, getMyPrivacy);
|
||||
router.put('/me/news', protect, updateMyNews);
|
||||
router.get('/me/export', protect, exportLimiter, exportMyData);
|
||||
router.post('/me/requests', protect, createMyRequest);
|
||||
|
||||
// Unsubscribe links (signed tokens, no login)
|
||||
router.get('/unsubscribe/:token', getUnsubscribe);
|
||||
router.post('/unsubscribe/:token', postUnsubscribe);
|
||||
router.get('/unsubscribe/:token/one-click', oneClickRedirect);
|
||||
router.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), oneClickUnsubscribe);
|
||||
|
||||
// Admin: data requests inbox
|
||||
router.get('/requests', protect, admin, listRequests);
|
||||
router.post('/requests/:id/complete', protect, admin, completeRequest);
|
||||
router.post('/requests/:id/reject', protect, admin, rejectRequest);
|
||||
|
||||
// Privacy notice: public reading, acceptance, and the admin editor
|
||||
router.get('/notice', getNotice);
|
||||
router.post('/me/notice/accept', protect, acceptMyNotice);
|
||||
router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
router.post('/operators', protect, admin, createOperator);
|
||||
router.put('/operators/:id', protect, admin, updateOperator);
|
||||
router.delete('/operators/:id', protect, admin, deleteOperator);
|
||||
router.get('/consent-log', protect, admin, consentLog);
|
||||
router.get('/access-log', protect, admin, accessLog);
|
||||
|
||||
// Admin: readiness, retention, breach register
|
||||
router.get('/readiness', protect, admin, getReadiness);
|
||||
router.get('/retention', protect, admin, getRetention);
|
||||
router.put('/retention', protect, admin, saveRetention);
|
||||
router.post('/retention/run', protect, admin, runRetentionNow);
|
||||
router.get('/breaches', protect, admin, listBreaches);
|
||||
router.post('/breaches', protect, admin, createBreach);
|
||||
router.put('/breaches/:id', protect, admin, updateBreach);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,21 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
|
||||
|
||||
// GET all payment provider transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, unscopedOnly, getAllProviderTransactions);
|
||||
|
||||
// GET all unreconciled transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
|
||||
|
||||
// POST reconcile a transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
|
||||
|
||||
// POST ignore a transaction
|
||||
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,54 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
getUserRegistrations,
|
||||
getRegistrationById,
|
||||
updateRegistrationStatus,
|
||||
cancelRegistration,
|
||||
getRegistrationsByEvent,
|
||||
createManualRegistration,
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
applyCoupon, removeCoupon,
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
|
||||
const { notOnSeasonPass } = require('../utils/seasonPass');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
|
||||
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
|
||||
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, scopeList, getRegistrations);
|
||||
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
|
||||
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
|
||||
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
|
||||
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
|
||||
const { protect } = require('../middleware/authMiddleware');
|
||||
|
||||
// Generate and download PDF
|
||||
router.post('/pdf', protect, generatePdf);
|
||||
|
||||
// Generate and download styled Excel (.xlsx)
|
||||
router.post('/excel', protect, generateExcel);
|
||||
|
||||
// Email PDF to current user
|
||||
router.post('/email', protect, emailPdf);
|
||||
|
||||
// Send PDF to current user's own WhatsApp
|
||||
router.post('/whatsapp', protect, whatsappPdf);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,16 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
|
||||
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
|
||||
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
|
||||
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, scopeList, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases } = require('../controllers/seasonPassController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a pass can span campuses; each event on it is checked against the person\'s campuses in the controller');
|
||||
|
||||
router.get('/mine', protect, myPurchases);
|
||||
router.post('/:id/buy', protect, buy);
|
||||
router.post('/purchases/:id/checkout', protect, payPurchase);
|
||||
|
||||
router.get('/', protect, supervisor, SPANS, listPasses);
|
||||
router.post('/', protect, supervisor, SPANS, createPass);
|
||||
router.put('/:id', protect, supervisor, SPANS, updatePass);
|
||||
router.delete('/:id', protect, supervisor, SPANS, deletePass);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,17 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getSections,
|
||||
createSection,
|
||||
deleteSection,
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, scopeList, getSections);
|
||||
router.post('/', protect, supervisor, scopeEvent({ from: req => req.body?.eventId, need: 'manage' }), createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,21 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
|
||||
|
||||
router.get('/by-slug/:slug', optionalAuth, getSeriesBySlug);
|
||||
router.post('/:id/follow', protect, followSeries);
|
||||
router.delete('/:id/follow', protect, unfollowSeries);
|
||||
|
||||
// The list filters each series' events by the person's campuses itself.
|
||||
router.get('/', protect, supervisor, SPANS, listSeries);
|
||||
router.post('/', protect, supervisor, SPANS, createSeries);
|
||||
router.put('/:id', protect, supervisor, SPANS, updateSeries);
|
||||
router.delete('/:id', protect, admin, deleteSeries);
|
||||
// Year-on-year report: only the events the person may see (filtered in the controller).
|
||||
router.get('/:id/compare', protect, supervisor, scopeList, compare);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/needs-setup', needsSetup);
|
||||
router.get('/all', protect, admin, getAllSettings);
|
||||
router.get('/', getSettings);
|
||||
router.put('/', protect, admin, updateSettings);
|
||||
router.post('/test-smtp', protect, admin, testSmtp);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { setupRegister, runSetup } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Step 1 of setup: create the admin account, returns a JWT token
|
||||
router.post('/register', setupRegister);
|
||||
|
||||
// Step 2 of setup: finalize settings (requires the token from /register)
|
||||
router.post('/', protect, admin, runSetup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,11 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
|
||||
// Staff never sees financial data, so this has no staff-accessible route.
|
||||
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,55 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
generateTickets,
|
||||
getTickets,
|
||||
getUserTickets,
|
||||
getTicketById,
|
||||
getTicketByQrCode,
|
||||
getScanPreview,
|
||||
scanTicket,
|
||||
getTicketsByEvent,
|
||||
markTicketsAsEmailSent,
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
|
||||
router.get('/', protect, supervisor, scopeList, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,53 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, uploadEventImage);
|
||||
|
||||
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
|
||||
// during first-time setup (no users yet).
|
||||
async function brandingAssetAccess(req, res, next) {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count === 0) return next(); // first-time setup
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
} catch {
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
}
|
||||
}
|
||||
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/logo', brandingAssetAccess, (req, res, next) => {
|
||||
uploadLogo.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadLogoImage);
|
||||
|
||||
// @route POST /api/uploads/favicon
|
||||
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
|
||||
uploadFavicon.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadFaviconImage);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,67 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
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('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
|
||||
// Protected routes
|
||||
router.route('/profile')
|
||||
.get(protect, getUserProfile)
|
||||
.put(protect, updateUserProfile);
|
||||
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
router.post('/verify-password', protect, loginLimiter, verifyPassword);
|
||||
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
|
||||
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
|
||||
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
.put(protect, admin, updateUser)
|
||||
.delete(protect, admin, deleteUser);
|
||||
|
||||
router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions);
|
||||
router.post('/:id/anonymize', protect, admin, anonymizeUser);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,33 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/volunteerController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Serving (anyone with an account can volunteer; only their own slots)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.get('/events/:eventId', protect, c.getEventRoles);
|
||||
router.post('/roles/:id/sign-up', protect, c.signUp);
|
||||
router.delete('/slots/:id', protect, c.withdraw);
|
||||
router.post('/slots/:id/swap', protect, c.requestSwap);
|
||||
router.delete('/swaps/:id', protect, c.cancelSwap);
|
||||
router.post('/swaps/:id/accept', protect, c.acceptSwap);
|
||||
router.post('/check-in', protect, c.checkIn);
|
||||
|
||||
// The roster on the day (staff)
|
||||
router.get('/manage/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/manage/events/:eventId', protect, staff, scopeEvent(), c.getRoster);
|
||||
router.get('/manage/events/:eventId/qr', protect, staff, scopeEvent(), c.getQr);
|
||||
router.post('/manage/slots/:id/check-in', protect, staff, scopeEvent({ resolve: 'volunteerSlot' }), c.checkInManual);
|
||||
|
||||
// Roles, people, settings and reports (supervisors)
|
||||
router.get('/manage/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/manage/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
router.post('/manage/events/:eventId/roles', protect, supervisor, scopeEvent({ need: 'manage' }), c.createRole);
|
||||
router.put('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.updateRole);
|
||||
router.delete('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.deleteRole);
|
||||
router.post('/manage/roles/:id/assign', protect, supervisor, scopeEvent({ resolve: 'servingRole' }), c.assign);
|
||||
router.delete('/manage/slots/:id', protect, supervisor, scopeEvent({ resolve: 'volunteerSlot' }), c.remove);
|
||||
router.get('/manage/hours', protect, supervisor, scopeList, c.hours);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,20 +0,0 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
|
||||
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,37 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
createInstanceHandler,
|
||||
deleteInstanceHandler,
|
||||
getStatusHandler,
|
||||
getQrHandler,
|
||||
requestCodeHandler,
|
||||
logoutHandler,
|
||||
startHandler,
|
||||
restartHandler,
|
||||
handleWebhook,
|
||||
} = require('../controllers/whatsappController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Public webhook (called by WAWP servers)
|
||||
router.post('/webhook', handleWebhook);
|
||||
|
||||
// Admin-only: credentials config
|
||||
router.get( '/config', protect, admin, getConfigHandler);
|
||||
router.post('/config', protect, admin, saveConfigHandler);
|
||||
|
||||
// Admin-only: instance lifecycle
|
||||
router.post('/create-instance', protect, admin, createInstanceHandler);
|
||||
router.post('/delete-instance', protect, admin, deleteInstanceHandler);
|
||||
|
||||
// Admin-only: session management
|
||||
router.get( '/status', protect, admin, getStatusHandler);
|
||||
router.get( '/qr', protect, admin, getQrHandler);
|
||||
router.post('/request-code', protect, admin, requestCodeHandler);
|
||||
router.post('/logout', protect, admin, logoutHandler);
|
||||
router.post('/start', protect, admin, startHandler);
|
||||
router.post('/restart', protect, admin, restartHandler);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.get('/webhooks', protect, admin, getWebhooks);
|
||||
router.post('/webhooks', protect, admin, createWebhook);
|
||||
router.delete('/webhooks/:id', protect, admin, removeWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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>;
|
||||
needsRehash(hash: string): 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 });
|
||||
}
|
||||
|
||||
// 2.0: a password saved with bcrypt (or weaker Argon2 settings) is re-hashed now we have it.
|
||||
const rehashed = auth.needsRehash(user.password) ? { password: await auth.hashPassword(input.password) } : {};
|
||||
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null, ...rehashed }, 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,345 @@
|
||||
/**
|
||||
* Corrections (1.52.0): putting registrations right when the normal flow didn't finish — tickets
|
||||
* held back by a required attendee form, tickets needed before it's paid, a balance to write off,
|
||||
* and check-ins after the event. Admins only, from Corrections; "Fill in later" (leaveFormForLater)
|
||||
* is for any staff at the door and in Manual registrations.
|
||||
*
|
||||
* Every change is in the audit log with who did it and the reason given.
|
||||
*/
|
||||
import { prisma as typedPrisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
|
||||
const prisma = typedPrisma as any;
|
||||
const { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers } = require('../utils/ticketUtils') as {
|
||||
generateTicketsForRegistration(id: string): Promise<any[]>;
|
||||
formsOutstanding(registration: string | object): Promise<number>;
|
||||
formPlaces(registrationOptions: any[]): number;
|
||||
formNeedsAnswers(form: any): boolean;
|
||||
};
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing') as { computeRegistrationTotalDue(registration: any, at: Date): number };
|
||||
const { recalculateRegistrationDiscount } = require('../utils/coupons') as { recalculateRegistrationDiscount(id: string): Promise<number> };
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
activeSessions(sessions: any[]): any[];
|
||||
sessionLabel(session: any, sessions: any[]): string;
|
||||
lineCovers(line: any, sessionId: string): boolean;
|
||||
lineSessions(line: any): string[] | null;
|
||||
redeemedForSession(usages: any[], sessionId: string | null): number;
|
||||
};
|
||||
|
||||
export type Actor = { id: string; role: string };
|
||||
type Audit = { actor: Actor; ip?: string };
|
||||
|
||||
/** The key a single-date event's check-ins are counted under. */
|
||||
export const WHOLE_EVENT = 'event';
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const realEmail = (email: string | null | undefined) => (email && !email.endsWith('@guest.local') ? email : null);
|
||||
|
||||
const REGISTRATION_INCLUDE = {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
formResponses: { select: { id: true } },
|
||||
attendees: { select: { id: true, name: true } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { name: true } },
|
||||
tranches: true,
|
||||
tickets: {
|
||||
include: { usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, holder: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function emailTicketsTo(registrationId: string, userId: string) {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
|
||||
const res: any = { status: () => res, json: () => {} };
|
||||
try {
|
||||
await emailTickets({ user: { id: userId }, body: { registrationId } }, res);
|
||||
} catch (e: any) {
|
||||
console.error('[corrections] Failed to send tickets:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvent(eventId: string) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, cashupStatus: true,
|
||||
sessions: { select: { id: true, label: true, startsAt: true, endsAt: true, cancelledAt: true } },
|
||||
form: { include: { fields: { select: { type: true } } } },
|
||||
},
|
||||
});
|
||||
return event || fail(404, 'Event not found');
|
||||
}
|
||||
|
||||
/** One registration as Corrections shows it: money, forms, tickets and their check-ins. */
|
||||
function correctionRow(reg: any, event: any, formRequired: boolean) {
|
||||
const now = new Date();
|
||||
const totalDue = round2(computeRegistrationTotalDue(reg, now));
|
||||
const paid = round2((reg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0));
|
||||
const active = sessions.activeSessions(event.sessions || []);
|
||||
const multi = event.scheduleType === 'sessions';
|
||||
let places = 0;
|
||||
let ticketPlaces = 0;
|
||||
const tickets: any[] = [];
|
||||
for (const ro of reg.registrationOptions || []) {
|
||||
places += ro.quantity || 0;
|
||||
const session = ro.sessionId ? active.find((s: any) => s.id === ro.sessionId) : null;
|
||||
const label = [ro.eventOption?.name, ro.variant?.name].filter(Boolean).join(' — ')
|
||||
+ (session ? ` (${sessions.sessionLabel(session, event.sessions)})` : '');
|
||||
for (const t of ro.tickets || []) {
|
||||
ticketPlaces += t.quantity || 0;
|
||||
const covered = multi ? (sessions.lineSessions(ro) ?? active.map((s: any) => s.id)).filter((id: string) => active.some((s: any) => s.id === id)) : [WHOLE_EVENT];
|
||||
const checkedIn: Record<string, number> = {};
|
||||
for (const key of covered) checkedIn[key] = sessions.redeemedForSession(t.usages, multi ? key : null);
|
||||
tickets.push({
|
||||
id: t.id,
|
||||
qrCode: t.qrCode,
|
||||
label,
|
||||
isMainTicket: !!ro.eventOption?.isMainTicket,
|
||||
name: t.attendee?.name || t.holder?.name || reg.user?.name || '',
|
||||
quantity: t.quantity || 1,
|
||||
sessionIds: covered,
|
||||
checkedIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ticketsDue = reg.status === 'paid' || !!reg.ticketsReleasedAt;
|
||||
const formsNeeded = formRequired && !reg.formWaivedAt ? Math.max(0, formPlaces(reg.registrationOptions) - (reg.formResponses || []).length) : 0;
|
||||
return {
|
||||
id: reg.id,
|
||||
status: reg.status,
|
||||
createdAt: reg.createdAt,
|
||||
user: { id: reg.user?.id, name: reg.user?.name || '', email: realEmail(reg.user?.email), phoneNumber: reg.user?.phoneNumber || null },
|
||||
totalDue,
|
||||
paid,
|
||||
balance: round2(Math.max(0, totalDue - paid)),
|
||||
places,
|
||||
ticketPlaces,
|
||||
missingTickets: ticketsDue && ticketPlaces < places,
|
||||
formsNeeded,
|
||||
formWaived: !!reg.formWaivedAt,
|
||||
ticketsReleased: !!reg.ticketsReleasedAt,
|
||||
complimentary: Number(reg.complimentaryDiscount || 0),
|
||||
tickets,
|
||||
};
|
||||
}
|
||||
|
||||
function eventSummary(event: any) {
|
||||
const active = sessions.activeSessions(event.sessions || []);
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
closed: event.cashupStatus === 'closed',
|
||||
formRequired: formNeedsAnswers(event.form),
|
||||
sessions: event.scheduleType === 'sessions'
|
||||
? active.map((s: any) => ({ id: s.id, label: sessions.sessionLabel(s, event.sessions), startsAt: s.startsAt }))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** An event's registrations (not cancelled) with what may need putting right. */
|
||||
export async function eventCorrections(eventId: string) {
|
||||
const event = await loadEvent(eventId);
|
||||
const formRequired = formNeedsAnswers(event.form);
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId, status: { not: 'cancelled' } },
|
||||
include: REGISTRATION_INCLUDE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return { event: eventSummary(event), registrations: regs.map((r: any) => correctionRow(r, event, formRequired)) };
|
||||
}
|
||||
|
||||
async function registrationRow(registrationId: string) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const event = await loadEvent(reg.eventId);
|
||||
return correctionRow(reg, event, formNeedsAnswers(event.form));
|
||||
}
|
||||
|
||||
async function activeRegistration(registrationId: string) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
if (reg.status === 'cancelled') return fail(400, 'This registration is cancelled.');
|
||||
return reg;
|
||||
}
|
||||
|
||||
async function waiveForm(reg: any, actorId: string) {
|
||||
if (reg.formWaivedAt || (await formsOutstanding(reg)) === 0) return false;
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { formWaivedAt: new Date(), formWaivedById: actorId, updatedAt: new Date() } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fill in later" at the door / in Manual registrations: the tickets stop waiting for the
|
||||
* attendee form, and the payer is sent a link to fill it in (`notify`).
|
||||
*/
|
||||
export async function leaveFormForLater(registrationId: string, { actor, ip }: Audit, { notify = true }: { notify?: boolean } = {}) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
const waived = await waiveForm(reg, actor.id);
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
if (created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
let sentTo: string[] = [];
|
||||
if (waived) {
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'door' }, ip });
|
||||
if (notify) {
|
||||
const { sendFormReminderMessage } = require('../utils/notifications') as { sendFormReminderMessage(id: string): Promise<string[]> };
|
||||
sentTo = await sendFormReminderMessage(reg.id).catch(() => []);
|
||||
}
|
||||
}
|
||||
return { ticketsIssued: created.length, sentTo };
|
||||
}
|
||||
|
||||
type IssueOptions = { skipForm?: boolean; payLater?: boolean; send?: boolean; reason?: string | null };
|
||||
|
||||
/**
|
||||
* Admin: issue a registration's tickets now — leaving the attendee form for later (`skipForm`)
|
||||
* and/or before it's paid (`payLater`, the balance is still owed).
|
||||
*/
|
||||
export async function issueTickets(registrationId: string, { actor, ip }: Audit, opts: IssueOptions) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
if (reg.status !== 'paid' && !reg.ticketsReleasedAt && !opts.payLater) {
|
||||
return fail(400, 'This registration isn\'t paid yet. Choose "pay later" or "make it free" to issue its tickets.');
|
||||
}
|
||||
const outstanding = opts.skipForm ? 0 : await formsOutstanding(reg);
|
||||
if (outstanding > 0) {
|
||||
return fail(400, `${outstanding} attendee form${outstanding === 1 ? ' is' : 's are'} still to fill in. Fill ${outstanding === 1 ? 'it' : 'them'} in, or choose to leave the form for later.`);
|
||||
}
|
||||
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
|
||||
const released = !!opts.payLater && reg.status !== 'paid' && !reg.ticketsReleasedAt;
|
||||
if (released) {
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { ticketsReleasedAt: new Date(), ticketsReleasedById: actor.id, updatedAt: new Date() } });
|
||||
}
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Registration', targetId: reg.id,
|
||||
metadata: { eventId: reg.eventId, formLeftForLater: waived, payLater: released, ticketsCreated: created.length, reason: opts.reason || null },
|
||||
ip,
|
||||
});
|
||||
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
|
||||
return { ticketsIssued: created.length, registration: await registrationRow(reg.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: write off what's still owed ("make it free") — it comes off as a complimentary
|
||||
* discount, so income stays what was really paid — and issue the tickets.
|
||||
*/
|
||||
export async function makeComplimentary(registrationId: string, { actor, ip }: Audit, opts: Omit<IssueOptions, 'payLater'>) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
return fail(400, e?.message || 'This event is closed.');
|
||||
}
|
||||
const full = await prisma.registration.findUnique({
|
||||
where: { id: reg.id },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const due = computeRegistrationTotalDue(full, new Date());
|
||||
const paid = (full.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const balance = round2(due - paid);
|
||||
if (!(balance > 0.004)) return fail(400, 'Nothing is owed on this registration.');
|
||||
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { complimentaryDiscount: round2(Number(full.complimentaryDiscount || 0) + balance), updatedAt: new Date() } });
|
||||
await recalculateRegistrationDiscount(reg.id);
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_complimentary', targetType: 'Registration', targetId: reg.id,
|
||||
metadata: { eventId: reg.eventId, amount: balance, reason: opts.reason || null }, ip,
|
||||
});
|
||||
|
||||
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
|
||||
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
return { amount: balance, ticketsIssued: created.length, registration: await registrationRow(reg.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: every paid registration (or one released unpaid) at an event that's missing tickets
|
||||
* gets them — forms still outstanding are left for later.
|
||||
*/
|
||||
export async function issueMissingTickets(eventId: string, { actor, ip }: Audit, { send = false }: { send?: boolean } = {}) {
|
||||
const { registrations } = await eventCorrections(eventId);
|
||||
const missing = registrations.filter((r: any) => r.missingTickets);
|
||||
let tickets = 0;
|
||||
let fixed = 0;
|
||||
const failed: { id: string; name: string; message: string }[] = [];
|
||||
for (const row of missing) {
|
||||
try {
|
||||
const reg = await activeRegistration(row.id);
|
||||
await waiveForm(reg, actor.id);
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
tickets += created.length;
|
||||
if (created.length > 0) fixed += 1;
|
||||
if (send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
} catch (e: any) {
|
||||
failed.push({ id: row.id, name: row.user.name, message: e?.message || 'Failed' });
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Event', targetId: eventId,
|
||||
metadata: { registrations: fixed, ticketsCreated: tickets, formLeftForLater: true, sent: send, failed: failed.length }, ip,
|
||||
});
|
||||
}
|
||||
return { registrations: fixed, tickets, failed };
|
||||
}
|
||||
|
||||
type CheckInOptions = { sessionId?: string | null; people?: number | null; present?: boolean; reason?: string | null };
|
||||
|
||||
/**
|
||||
* Admin: check a ticket in (or undo it) whatever the time — after the event or session has
|
||||
* ended, outside the check-in window, or once the event is closed. Recorded as a manual
|
||||
* check-in; no check-in message is sent.
|
||||
*/
|
||||
export async function correctCheckIn(ticketId: string, { actor, ip }: Audit, opts: CheckInOptions) {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: { usages: true, event: { select: { id: true, scheduleType: true } }, registrationOption: { select: { sessionId: true, sessionIds: true, registration: { select: { status: true } } } } },
|
||||
});
|
||||
if (!ticket) return fail(404, 'Ticket not found');
|
||||
if (ticket.registrationOption?.registration?.status === 'cancelled') return fail(400, 'This registration is cancelled.');
|
||||
const multi = ticket.event.scheduleType === 'sessions';
|
||||
let sessionId: string | null = null;
|
||||
if (multi) {
|
||||
const session = opts.sessionId ? await prisma.eventSession.findUnique({ where: { id: opts.sessionId } }) : null;
|
||||
if (!session || session.eventId !== ticket.eventId) return fail(400, 'Choose one of this event\'s sessions.');
|
||||
if (session.cancelledAt) return fail(400, 'That session was cancelled.');
|
||||
if (ticket.registrationOption && !sessions.lineCovers(ticket.registrationOption, session.id)) return fail(400, 'That ticket isn\'t booked for this session.');
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
const forSession = ticket.usages.filter((u: any) => (u.sessionId || null) === sessionId);
|
||||
const redeemed = sessions.redeemedForSession(ticket.usages, sessionId);
|
||||
const quantity = ticket.quantity || 1;
|
||||
let changed = 0;
|
||||
if (opts.present === false) {
|
||||
if (forSession.length === 0) return fail(400, 'This ticket isn\'t checked in.');
|
||||
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map((u: any) => u.id) } } });
|
||||
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
|
||||
changed = redeemed;
|
||||
} else {
|
||||
const left = quantity - redeemed;
|
||||
if (left <= 0) return fail(400, 'Everyone on this ticket is already checked in.');
|
||||
const people = Math.min(Math.max(1, Math.trunc(Number(opts.people)) || left), left);
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: people, sessionId, manual: true } });
|
||||
if (!multi && redeemed + people >= quantity) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
|
||||
changed = people;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'checkin_corrected', targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { eventId: ticket.eventId, sessionId, present: opts.present !== false, people: changed, reason: opts.reason || null }, ip,
|
||||
});
|
||||
const after = await prisma.ticketUsage.findMany({ where: { ticketId: ticket.id }, select: { quantityRedeemed: true, sessionId: true } });
|
||||
return { checkedIn: sessions.redeemedForSession(after, sessionId), quantity };
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Bundle deals and coupon codes (1.51.9). Moved from bundleController.js and couponController.js
|
||||
* so v1 and /api/v2 share them; matching codes and working out discounts stays in
|
||||
* utils/coupons.js and utils/bundles.js.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const bundlesUtil = require('../utils/bundles') as {
|
||||
KINDS: string[];
|
||||
describeBundle(b: any): string;
|
||||
describeBundleItems(b: any): string;
|
||||
isBundleAvailable(b: any, now: Date): boolean;
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
normalizeCode(code: unknown): string;
|
||||
previewCoupon(args: object): Promise<{ coupon: any; discount: number; bundles: { discount: number; applications: any[] } }>;
|
||||
previewBundles(eventId: string, lines: any[]): Promise<{ discount: number; applications: any[] }>;
|
||||
buildCouponLines(reg: any): any[];
|
||||
describeCoupon(c: any): string;
|
||||
countCouponUses(couponId: string, userId: string | null): Promise<{ usesTotal: number }>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as { couponAllowed(scope: object | null, campusIds: Array<string | null>): boolean };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role; email?: string };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
const round2 = (n: unknown) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n: number) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** Plain errors from the parsers and helpers are the caller's mistake (400), like v1. */
|
||||
async function clientErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'That could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const optionalDate = (v: unknown, label: string): Date | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(String(v));
|
||||
if (isNaN(d.getTime())) fail(400, `${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
// ─── Pricing a basket (bundle quotes and coupon checks) ──────────────────────
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the signed-in buyer, or — for staff — the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
export async function basketBuyer(actor: Actor | null, forUserId: string | null | undefined) {
|
||||
const isStaff = !!(actor && STAFF_ROLES.includes(actor.role));
|
||||
let buyer: { id: string; role: string; email?: string } | null = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
if (isStaff && forUserId) buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
else if (isStaff && forUserId === null) buyer = null;
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
export type BasketLine = { eventOptionId: string; variantId?: string | null; quantity: number; sessionIds?: string[] };
|
||||
|
||||
/** Prices a basket the way registering will, as coupon/bundle lines. */
|
||||
export async function priceBasket(event: { eventOptions: any[] } & Record<string, any>, options: BasketLine[], buyer: any) {
|
||||
const registrationOptions: any[] = [];
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const { isPickOption } = require('../utils/sessions') as { isPickOption(ev: any, opt: any): boolean };
|
||||
const perSession = (o: BasketLine) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId ? await pricing.resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await pricing.resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }] });
|
||||
}
|
||||
return coupons.buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
const basketEvent = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** The bundle deals a basket gets, before any coupon. */
|
||||
export function quoteBundles(input: { eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null) {
|
||||
return clientErrors(async () => {
|
||||
if (!input.eventId || !Array.isArray(input.options)) fail(400, 'eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = round2(lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0));
|
||||
const b = await coupons.previewBundles(event.id, lines);
|
||||
return { subtotal, bundleDiscount: b.discount, bundles: b.applications, total: round2(Math.max(0, subtotal - b.discount)) };
|
||||
});
|
||||
}
|
||||
|
||||
export type CodeCheck =
|
||||
| { valid: true; code: string; type: string; value: number; summary: string; description: string | null; discount: number; bundleDiscount: number; bundles: any[]; subtotal: number; total: number }
|
||||
| { valid: false; message: string };
|
||||
|
||||
/**
|
||||
* Checks a code against a basket. Staff can check on an attendee's behalf (`forUserId`), or
|
||||
* leave it out for a new attendee — restricted codes are then checked when they register.
|
||||
*/
|
||||
export function checkCode(input: { code?: string; eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null): Promise<CodeCheck> {
|
||||
return clientErrors(async () => {
|
||||
if (!input.code || !input.eventId || !Array.isArray(input.options) || input.options.length === 0) fail(400, 'code, eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer, isStaff } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0);
|
||||
try {
|
||||
const { coupon, discount, bundles } = await coupons.previewCoupon({ code: input.code, eventId: event.id, user: buyer, lines, deferRestrictions: isStaff && !buyer });
|
||||
return {
|
||||
valid: true as const, code: coupon.code, type: coupon.type, value: coupon.value, summary: coupons.describeCoupon(coupon), description: coupon.description,
|
||||
discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (!e.isCouponError) throw e;
|
||||
return { valid: false as const, message: e.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i: any) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find((v: any) => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
|
||||
/** What the deal is worth at standard prices, and a short "save …" line (or null when the headline says it). */
|
||||
function valueOf(bundle: any): { standardValue: number | null; savingText: string | null } {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m: number, i: any) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s: number, i: any) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
function shapeBundle(bundle: any, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }: any) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return { ...bundle, items, summary: bundlesUtil.describeBundleItems(bundle), headline: bundlesUtil.describeBundle(bundle), ...valueOf(bundle), available: bundlesUtil.isBundleAvailable(bundle, now) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A create/update body as Prisma data plus the item list. On update, fields left out stay as
|
||||
* they are; when any pricing field is sent, the whole deal (saved + new values) is checked.
|
||||
*/
|
||||
async function parseBundleBody(body: any, eventId: string, { isCreate, current = null }: { isCreate: boolean; current?: any }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) fail(400, 'Give the bundle a name');
|
||||
if (name.length > 80) fail(400, 'Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Available from" must be before "Available until"');
|
||||
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items: undefined };
|
||||
|
||||
const pick = (f: string) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!bundlesUtil.KINDS.includes(kind)) fail(400, 'Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map<string, { eventOptionId: string; variantId: string | null; quantity: number }>();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) fail(400, 'Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
const items = [...merged.values()];
|
||||
if (items.length === 0) fail(400, kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) fail(400, 'A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) fail(400, 'At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) fail(400, 'Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) fail(400, 'Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) fail(400, 'Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) fail(400, 'Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: items.map(i => i.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) fail(400, 'One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row!.variants.some(v => v.id === i.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
const eventOr404 = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** An event's bundle deals with how often each has been used and the discount given. */
|
||||
export async function listBundles(eventId: string) {
|
||||
await eventOr404(eventId);
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
return bundles.map(b => ({
|
||||
...shapeBundle(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createBundle(eventId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const event = await eventOr404(eventId);
|
||||
const parsed = await parseBundleBody(body || {}, event.id, { isCreate: true });
|
||||
const bundle = await prisma.eventBundle.create({ data: { ...(parsed.data as any), eventId: event.id, items: { create: parsed.items } }, include: DETAIL_INCLUDE });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_created', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: bundlesUtil.describeBundle(bundle) }, ip: ctx.ip,
|
||||
});
|
||||
return { ...shapeBundle(bundle), uses: 0, registrations: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Edits a deal (or switches it off). Registrations that have it are re-worked only when their own items change. */
|
||||
export function updateBundle(bundleId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId }, include: { items: true } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const parsed = await parseBundleBody(body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_updated', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(body || {}) }, ip: ctx.ip,
|
||||
});
|
||||
return shapeBundle(bundle);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a deal; one that's been used is switched off instead, so registrations keep their price. */
|
||||
export async function deleteBundle(bundleId: string, actor: Actor, ctx: Ctx) {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
else await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_deleted', targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 }, ip: ctx.ip,
|
||||
});
|
||||
return used > 0
|
||||
? { switchedOff: true as const, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true as const, message: 'Bundle deleted' };
|
||||
}
|
||||
|
||||
/** The deals shown on an event's page: available bundles, without internal fields. */
|
||||
export async function publicBundlesForEvent(eventId: string) {
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId, isActive: true }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const now = new Date();
|
||||
return bundles.filter(b => bundlesUtil.isBundleAvailable(b, now)).map(b => {
|
||||
const s = shapeBundle(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map((i: any) => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses.
|
||||
const couponCampuses = (coupon: any): Array<string | null> => (coupon.events || []).map((ce: any) => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope: object | null, eventIds: string[] | undefined) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && campusScope.couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
|
||||
const optionalInt = (v: unknown, label: string, min = 0): number | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) fail(400, `${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** A create/update body as Prisma data plus the event, ticket and person lists. On update, fields left out stay. */
|
||||
async function parseCouponBody(body: any, { isCreate }: { isCreate: boolean }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = coupons.normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) fail(400, 'Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) fail(400, "Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) fail(400, 'Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) fail(400, 'A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some((r: string) => !ROLES.includes(r))) fail(400, 'Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) fail(400, 'Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
const eventIds: string[] | undefined = body.eventIds !== undefined ? [...new Set<string>((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes: Array<{ eventOptionId: string; variantId: string | null }> | undefined = body.options !== undefined
|
||||
? (body.options || []).filter((o: any) => o && o.eventOptionId).map((o: any) => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds: string[] | undefined = body.userIds !== undefined ? [...new Set<string>((body.userIds || []).filter(Boolean))] : undefined;
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) return fail(400, 'One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) fail(400, 'Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }: Awaited<ReturnType<typeof parseCouponBody>>, isCreate: boolean) {
|
||||
const out: Record<string, any> = {};
|
||||
const replace = isCreate ? {} : { deleteMany: {} };
|
||||
if (eventIds !== undefined) out.events = { ...replace, create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...replace, create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...replace, create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Codes the caller may see, with how often each has been used and the discount given. */
|
||||
export async function listCoupons(ctx: Ctx) {
|
||||
const list = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } })).filter(c => campusScope.couponAllowed(ctx.scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
return list.map(c => ({ ...c, summary: coupons.describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0 }));
|
||||
}
|
||||
|
||||
/** One code with its redemptions. */
|
||||
export async function getCoupon(couponId: string, ctx: Ctx) {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: couponId },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !campusScope.couponAllowed(ctx.scope, couponCampuses(coupon))) return fail(404, 'Coupon not found');
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon) };
|
||||
}
|
||||
|
||||
export function createCoupon(body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const parsed = await parseCouponBody(body || {}, { isCreate: true });
|
||||
if (!(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
const coupon = await prisma.coupon.create({ data: { ...(parsed.data as any), createdById: actor.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE });
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: ctx.ip });
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a code (or turns it off). Discounts already applied keep their locked amount; a used code can't be renamed. */
|
||||
export function updateCoupon(couponId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: couponId }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
if (!existing || !campusScope.couponAllowed(ctx.scope, couponCampuses(existing))) return fail(404, 'Coupon not found');
|
||||
const parsed = await parseCouponBody({ type: existing.type, ...body }, { isCreate: false });
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
if (await prisma.couponRedemption.count({ where: { couponId: existing.id } }) > 0) fail(400, 'This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.');
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE });
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(body || {});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: ctx.ip,
|
||||
});
|
||||
const { usesTotal } = await coupons.countCouponUses(coupon.id, null);
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: usesTotal };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and bundle-deal uses for the Discounts report, newest first. `scopeWhere` limits it to
|
||||
* the caller's campuses (`{ event: … }` or `{}`). Bundle dates go by when the registration was made.
|
||||
*/
|
||||
export async function listRedemptions(opts: { eventIds: string[]; from: Date | null; to: Date | null }, scopeWhere: object) {
|
||||
const range = (opts.from || opts.to) ? { createdAt: { ...(opts.from ? { gte: opts.from } : {}), ...(opts.to ? { lte: opts.to } : {}) } } : {};
|
||||
const events = opts.eventIds.length ? { eventId: { in: opts.eventIds } } : {};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' }, ...events, ...scopeWhere }, ...range },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...events, ...range, ...scopeWhere } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
return [
|
||||
...rows.map(r => ({ ...r, kind: 'coupon' as const, uses: 1, summary: coupons.describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id, kind: 'bundle' as const, uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: bundlesUtil.describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -1,19 +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.
|
||||
* 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. couponError or a code the caller can switch on. */
|
||||
/** Extra, e.g. a code the caller can switch on. */
|
||||
readonly code?: string;
|
||||
readonly v1Status?: number;
|
||||
|
||||
constructor(statusCode: number, message: string, code?: string) {
|
||||
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): never => {
|
||||
throw new ServiceError(statusCode, message, code);
|
||||
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' });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,10 @@ import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
|
||||
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
|
||||
generateTicketsForRegistration(id: string): Promise<unknown>;
|
||||
formsOutstanding(registration: string | object): Promise<number>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
@@ -410,8 +413,7 @@ async function create(input: CreateRegistrationInput, actor: Actor) {
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
|
||||
if (!(form && form.isRequired)) {
|
||||
if ((await formsOutstanding(registrationId)) === 0) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registrationId);
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Event series and season passes (1.51.9). Moved from seriesController.js and
|
||||
* seasonPassController.js so v1 and /api/v2 share them; the rules themselves stay in
|
||||
* utils/series.js and utils/seasonPass.js.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const seriesUtil = require('../utils/series') as {
|
||||
cleanSeriesInput(body: any, current?: any): any;
|
||||
publicEventWhere(now?: Date): object;
|
||||
splitEvents(events: any[]): { upcoming: any[]; past: any[] };
|
||||
follow(seriesId: string, userId: string): Promise<unknown>;
|
||||
unfollow(seriesId: string, userId: string): Promise<unknown>;
|
||||
compareSeries(seriesId: string, opts: { scopeWhere: object }): Promise<unknown>;
|
||||
};
|
||||
const passes = require('../utils/seasonPass') as {
|
||||
PASS_INCLUDE: any;
|
||||
passView(pass: any, now?: Date): any;
|
||||
cleanPassInput(body: any, seriesId: string, current?: any): Promise<{ data: any; items: any[] | undefined }>;
|
||||
loadPass(id: string): Promise<any>;
|
||||
buyPass(passId: string, userId: string, opts: object): Promise<any>;
|
||||
startPassCheckout(purchaseId: string, userId: string, opts: object): Promise<any>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as {
|
||||
canView(scope: object | null, campusId: string | null): boolean;
|
||||
canManage(scope: object, campusId: string | null): boolean;
|
||||
eventWhere(scope: object | null): object;
|
||||
};
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
/** A taken web address is a CONFLICT with a message people understand. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another series already uses that web address');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The series could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const auditSeries = (actor: Actor, ctx: Ctx, action: string, series: { id: string; name: string }, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'EventSeries', targetId: series.id, metadata: { name: series.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every series with its events and number of followers. A series spans campuses: campus-scoped
|
||||
* staff see every series, but only their campuses' events in it.
|
||||
*/
|
||||
export async function listSeries(ctx: Ctx) {
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
return rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => campusScope.canView(ctx.scope, e.campusId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSeries(body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const series = await prisma.eventSeries.create({ data: seriesUtil.cleanSeriesInput(body) });
|
||||
auditSeries(actor, ctx, 'series_created', series);
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSeries(seriesId: string, body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
const data = seriesUtil.cleanSeriesInput(body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
auditSeries(actor, ctx, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a series: its events stay (without a series) and followers are dropped. Not once passes are sold. */
|
||||
export async function deleteSeries(seriesId: string, actor: Actor, ctx: Ctx) {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
fail(409, 'People have bought season passes for this series, so it can\'t be deleted.');
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
auditSeries(actor, ctx, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
}
|
||||
|
||||
/** A series' public page: details, upcoming and past public events, its passes, and whether `userId` follows it. */
|
||||
export async function seriesPage(slug: string, userId: string | null) {
|
||||
const series = await prisma.eventSeries.findUnique({ where: { slug: String(slug) }, select: { id: true, name: true, slug: true, description: true, picture: true } });
|
||||
if (!series) return fail(404, 'Series not found');
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...seriesUtil.publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = seriesUtil.splitEvents(events);
|
||||
const following = userId ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId } } })) : false;
|
||||
// Passes switched on; `unavailable` says why one can't be bought now.
|
||||
const list = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE }))
|
||||
.map(p => passes.passView(p))
|
||||
.map(({ items, ...p }: any) => ({ ...p, items: items.map(({ campusId: _c, ...i }: any) => i) }));
|
||||
return { ...series, upcoming, past, following, passes: list };
|
||||
}
|
||||
|
||||
/** Year on year: each event in the series (that the caller may see), oldest first. */
|
||||
export function compareSeries(seriesId: string, ctx: Ctx) {
|
||||
return seriesUtil.compareSeries(seriesId, { scopeWhere: campusScope.eventWhere(ctx.scope) });
|
||||
}
|
||||
|
||||
export async function setFollowing(seriesId: string, userId: string, following: boolean) {
|
||||
try {
|
||||
if (following) await seriesUtil.follow(seriesId, userId);
|
||||
else await seriesUtil.unfollow(seriesId, userId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Could not change following');
|
||||
}
|
||||
return { following };
|
||||
}
|
||||
|
||||
// ─── Season passes ───────────────────────────────────────────────────────────
|
||||
|
||||
const auditPass = (actor: Actor, ctx: Ctx, pass: { id: string; name: string }, action: string, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id, metadata: { action, name: pass.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
function assertCampuses(ctx: Ctx, campusIds: Array<string | null>) {
|
||||
if (ctx.scope && campusIds.some(c => !campusScope.canManage(ctx.scope!, c))) {
|
||||
fail(403, 'You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
}
|
||||
}
|
||||
|
||||
const asServiceError = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Something went wrong with the pass');
|
||||
}
|
||||
};
|
||||
|
||||
/** A series' passes (or all) with their events, price shares, and how many were sold or are unpaid. */
|
||||
export async function listPasses(seriesId?: string) {
|
||||
const rows = await prisma.seasonPass.findMany({ where: seriesId ? { seriesId } : {}, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
const count = (passId: string, status: string) => sold.filter(s => s.passId === passId && s.status === status).reduce((n, s) => n + (s._sum.quantity || 0), 0);
|
||||
return rows.map(p => ({ ...passes.passView(p), sold: count(p.id, 'paid'), unpaid: count(p.id, 'pending') }));
|
||||
}
|
||||
|
||||
export function createPass(body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const seriesId = String(body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) fail(404, 'Series not found');
|
||||
const { data, items } = await passes.cleanPassInput(body, seriesId);
|
||||
assertCampuses(ctx, (items || []).map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({ data: { ...data, seriesId, items: { create: (items || []).map(({ campusId: _c, ...i }) => i) } } });
|
||||
auditPass(actor, ctx, pass, 'created', { events: (items || []).length, price: pass.price });
|
||||
return passes.passView(await passes.loadPass(pass.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a pass. Its events and price can't change once somebody has bought it. */
|
||||
export function updatePass(passId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
fail(409, 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.');
|
||||
}
|
||||
if (items) assertCampuses(ctx, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId: _c, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
auditPass(actor, ctx, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
return passes.passView(await passes.loadPass(current.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a pass nobody has bought (otherwise switch it off). */
|
||||
export function deletePass(passId: string, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) fail(409, 'People have bought this pass, so it can\'t be deleted — switch it off instead.');
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
auditPass(actor, ctx, current, 'deleted');
|
||||
});
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const backTo = (slug: string) => {
|
||||
const back = `${frontendUrl()}/series/${slug}`;
|
||||
return { successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed` };
|
||||
};
|
||||
|
||||
/** Buys passes: registers the buyer at every event on it, then a checkout (or tickets when it's free). */
|
||||
export function buyPass(passId: string, userId: string, opts: { quantity?: number; provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: passId }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return fail(404, 'Season pass not found');
|
||||
return passes.buyPass(passId, userId, { quantity: opts.quantity ?? 1, provider: opts.provider || null, ...backTo(pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
/** Pays what's still owed on a pass purchase (a new checkout). The buyer only. */
|
||||
export function payPassPurchase(purchaseId: string, userId: string, opts: { provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: purchaseId }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== userId) return fail(404, 'Purchase not found');
|
||||
return passes.startPassCheckout(purchaseId, userId, { provider: opts.provider || null, ...backTo(purchase.pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
export function myPassPurchases(userId: string) {
|
||||
return prisma.seasonPassPurchase.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const ACCESS_ACTIONS = {
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
ai_feedback: 'Feedback comments summarised by the AI helper',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -256,7 +256,8 @@ async function registrationBundles(registration, db = prisma) {
|
||||
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
|
||||
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
|
||||
* coupon's amount (capped the same way): used when only prices changed, e.g. an
|
||||
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
|
||||
* early-bird tier expiring. A balance an admin wrote off (`complimentaryDiscount`, 1.52.0)
|
||||
* comes off last, as a fixed amount. Updates bundleDiscount and discountTotal and returns
|
||||
* discountTotal. Pass `tx` to run inside the caller's transaction.
|
||||
*/
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
|
||||
@@ -280,7 +281,7 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma, { ke
|
||||
}
|
||||
total += amount;
|
||||
}
|
||||
total = round2(total);
|
||||
total = round2(total + Number(registration.complimentaryDiscount || 0));
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
|
||||
|
||||
const MAX_MINUTES = 30;
|
||||
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
||||
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
|
||||
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
|
||||
const ALLOWED_WRITES = ['/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
|
||||
|
||||
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
|
||||
function impersonationClaim(req) {
|
||||
|
||||
@@ -34,7 +34,7 @@ function getBackendUrl() {
|
||||
function calendarLinkRow(eventId) {
|
||||
const backendUrl = getBackendUrl();
|
||||
if (!backendUrl || !eventId) return '';
|
||||
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
|
||||
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
|
||||
}
|
||||
|
||||
function getOrg() {
|
||||
@@ -1512,10 +1512,49 @@ async function sendAttendeeFormLinkMessage(attendee) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
// "Fill in later" (1.52.0): the attendee form was left for later at the door, so the payer is
|
||||
// sent a link to the forms still to do — by email and/or WhatsApp as they prefer.
|
||||
async function sendFormReminderMessage(registrationId) {
|
||||
const org = getOrg();
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: { select: { title: true } }, user: true } });
|
||||
if (!reg || !reg.user) return [];
|
||||
const { shouldEmail, waText, canWhatsApp } = require('./notify');
|
||||
const eventTitle = reg.event?.title || 'the event';
|
||||
const link = `${org.url}/forms?registrationId=${encodeURIComponent(reg.id)}`;
|
||||
const sent = [];
|
||||
|
||||
if (reg.user.email && !reg.user.email.endsWith('@guest.local') && shouldEmail(reg.user)) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your attendee form</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thanks for registering for <strong>${eventTitle}</strong>. We still need the attendee form — it only takes a minute.</p>
|
||||
${ctaButton('Fill in the form', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your attendee form for ${eventTitle}` });
|
||||
try {
|
||||
await sendMail({ to: reg.user.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${reg.user.name},
|
||||
|
||||
Thanks for registering for ${eventTitle}. We still need the attendee form:
|
||||
${link}
|
||||
|
||||
${org.name}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[form reminder] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (canWhatsApp(reg.user)) {
|
||||
await waText(reg.user, `Hi ${reg.user.name}, thanks for registering for *${eventTitle}*. We still need your attendee form: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEventNotifyEmails,
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendFormReminderMessage,
|
||||
sendRegistrationEmails,
|
||||
sendRegistrationUpdatedEmails,
|
||||
sendPaymentEmails,
|
||||
|
||||
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
|
||||
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
|
||||
function payfastNotifyUrl(req) {
|
||||
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
|
||||
return base ? `${base}/api/webhooks/payfast` : '';
|
||||
return base ? `${base}/api/v2/webhooks/payfast` : '';
|
||||
}
|
||||
|
||||
/** Why PayFast can't reach the notify URL (null when it can). */
|
||||
|
||||
@@ -96,10 +96,10 @@ function buildLineItems(registrationOptions) {
|
||||
/**
|
||||
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
|
||||
* Registration.discountTotal less the bundle part, both always present.
|
||||
* Registration.discountTotal less the bundle and complimentary parts, all always present.
|
||||
*/
|
||||
function couponDiscountLine(registration) {
|
||||
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
|
||||
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0) - Number(registration?.complimentaryDiscount || 0)) * 100) / 100;
|
||||
if (!(amount > 0.001)) return null;
|
||||
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
|
||||
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
|
||||
@@ -118,9 +118,15 @@ function bundleDiscountLine(registration) {
|
||||
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
|
||||
}
|
||||
|
||||
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
|
||||
/** A balance written off by an admin (Corrections → Make it free, 1.52.0). */
|
||||
function complimentaryLine(registration) {
|
||||
const amount = Number(registration?.complimentaryDiscount || 0);
|
||||
return amount > 0.001 ? { amount, label: 'Complimentary' } : null;
|
||||
}
|
||||
|
||||
/** Bundle saving, coupon discount, then anything made complimentary, as display lines ({ label, amount }). */
|
||||
function discountLines(registration) {
|
||||
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
|
||||
return [bundleDiscountLine(registration), couponDiscountLine(registration), complimentaryLine(registration)].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user