Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8805101929 | ||
|
|
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 |
+394
@@ -7,6 +7,400 @@ 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
|
||||
|
||||
@@ -311,6 +311,24 @@ and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
|
||||
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
|
||||
or checks anything out: they would be built into the release, or block the checkout. Move added
|
||||
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
|
||||
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
|
||||
|
||||
```bash
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh --check
|
||||
```
|
||||
|
||||
An install still on an older release can run the new check without updating anything — fetch the
|
||||
script on its own and run it from the install folder:
|
||||
|
||||
```bash
|
||||
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
|
||||
bash /tmp/deploy.sh --check
|
||||
```
|
||||
|
||||
### Upgrading Node
|
||||
|
||||
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
|
||||
|
||||
+17
-3
@@ -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,8 +903,8 @@ 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
|
||||
- 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
|
||||
@@ -938,4 +952,4 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
+108
@@ -12,6 +12,114 @@ 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
|
||||
|
||||
+11
-1167
File diff suppressed because it is too large
Load Diff
Generated
+235
-45
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "2.0.0",
|
||||
"hasInstallScript": true,
|
||||
"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",
|
||||
@@ -23,7 +24,6 @@
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.3.0",
|
||||
"pdfkit": "^0.17.1",
|
||||
"pg-boss": "^11.1.2",
|
||||
@@ -1729,6 +1729,238 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
|
||||
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@node-rs/argon2-android-arm-eabi": "2.2.2",
|
||||
"@node-rs/argon2-android-arm64": "2.2.2",
|
||||
"@node-rs/argon2-darwin-arm64": "2.2.2",
|
||||
"@node-rs/argon2-darwin-x64": "2.2.2",
|
||||
"@node-rs/argon2-freebsd-x64": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
|
||||
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
|
||||
"@node-rs/argon2-linux-x64-musl": "2.2.2",
|
||||
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
|
||||
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
|
||||
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
|
||||
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
|
||||
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
|
||||
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
|
||||
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
|
||||
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
|
||||
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
|
||||
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
|
||||
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
|
||||
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
|
||||
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
@@ -7492,26 +7724,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -9096,12 +9308,6 @@
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.15",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz",
|
||||
@@ -9421,22 +9627,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "2.0.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -17,10 +17,12 @@
|
||||
"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",
|
||||
@@ -34,7 +36,6 @@
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.3.0",
|
||||
"pdfkit": "^0.17.1",
|
||||
"pg-boss": "^11.1.2",
|
||||
|
||||
@@ -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 };
|
||||
+15
-1375
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,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 };
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const { publicOptions } = require('../utils/volunteers');
|
||||
const { campusesEnabled, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor, canView } = require('../utils/campusScope');
|
||||
const seriesUtil = require('../utils/series');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
// Setting events up is shared with /api/v2/events (TypeScript, 1.51.7).
|
||||
const eventService = require('../services/events');
|
||||
const { SERIES_SELECT, SESSIONS_INCLUDE, withSchedule } = eventService;
|
||||
const details = require('../services/eventDetails');
|
||||
|
||||
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
|
||||
// @route POST /api/events/:id/copy-from
|
||||
@@ -222,173 +220,9 @@ const getEventsAll = async (req, res) => {
|
||||
// @access Public
|
||||
const getEventById = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
// Determine if Prisma Client supports EarlyBirdTier / OptionVariant
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT };
|
||||
|
||||
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
||||
// event edit screen's Notifications step; public callers don't need this.
|
||||
const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
if (isStaffOrHigherForNotify) {
|
||||
includeObj.createdBy = { select: { id: true, name: true, email: true } };
|
||||
includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } };
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
// Try to include attachments via Prisma if the client has the model
|
||||
const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
||||
if (canUsePrismaAttachments) {
|
||||
try {
|
||||
// @ts-ignore - runtime guard ensures this exists
|
||||
includeObj.attachments = true;
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj });
|
||||
} catch (e) {
|
||||
// If relation/table does not exist (migration not deployed), fall back to fetching without attachments
|
||||
try {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
||||
} catch (e2) {
|
||||
// Notify-recipients relation not migrated yet either — fall back further
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
||||
}
|
||||
// And we will source attachments from the filesystem manifest below
|
||||
}
|
||||
} else {
|
||||
// No model available on the Prisma client; fetch without attachments
|
||||
try {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
||||
} catch (e) {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
||||
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
|
||||
// public view of other campuses' events (and not their notification recipients).
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
// goLiveAt may not exist on older schemas — tolerate that read failing without
|
||||
// affecting the isActive gate above.
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve attachments
|
||||
let attachments = [];
|
||||
if (event.attachments && Array.isArray(event.attachments)) {
|
||||
attachments = event.attachments;
|
||||
} else {
|
||||
// Fall back to manifest on disk if DB attachments are not available
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const list = JSON.parse(raw) || [];
|
||||
attachments = list;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Try include form definition if available
|
||||
let form = null;
|
||||
try {
|
||||
if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') {
|
||||
const f = await prisma.eventForm.findUnique({
|
||||
where: { eventId: eventId },
|
||||
include: { fields: true },
|
||||
});
|
||||
if (f) {
|
||||
form = {
|
||||
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,
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Compute availableCount per option and variant
|
||||
let eventOptionsWithStock = event.eventOptions || [];
|
||||
try {
|
||||
if (canIncludeVariants) {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
// Picked-session options: the stock limit is places per session.
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability = {};
|
||||
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 availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
// Per-variant stock
|
||||
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
||||
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, variants: variantsWithStock };
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: fall back to options without stock info
|
||||
}
|
||||
|
||||
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const mapped = withSchedule({
|
||||
...event,
|
||||
eventOptions: eventOptionsWithStock,
|
||||
picture: toAbsoluteUrl(req, event.picture),
|
||||
attachments: (attachments || []).map(att => ({
|
||||
...att,
|
||||
url: toAbsoluteUrl(req, att.url)
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
res.json(await details.getEventDetail(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) }, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -397,47 +231,12 @@ const getEventById = async (req, res) => {
|
||||
// @access Public (same visibility gating as getEventById)
|
||||
const getEventIcs = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
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, sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
const { buildEventIcs } = require('../utils/icsUtils');
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
||||
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
||||
frontendUrl = `https://${frontendUrl}`;
|
||||
}
|
||||
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
||||
|
||||
const { filename, ics } = await details.eventCalendar(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) });
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(ics);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -491,49 +290,14 @@ const deleteEvent = async (req, res) => {
|
||||
};
|
||||
|
||||
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
|
||||
function parseStaffPrice(value, current = null) {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(value);
|
||||
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
// @desc Create event option
|
||||
// @route POST /api/events/:id/options
|
||||
// @access Private/Admin
|
||||
const createEventOption = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId: req.params.id,
|
||||
name,
|
||||
price: parseFloat(price),
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
|
||||
}
|
||||
});
|
||||
sessionsUtil.forgetSchedule(req.params.id);
|
||||
|
||||
res.status(201).json(eventOption);
|
||||
res.status(201).json(await details.createOption(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -542,131 +306,9 @@ const createEventOption = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const updateEventOption = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { earlyBirdTiers: true }
|
||||
});
|
||||
|
||||
if (!eventOption) {
|
||||
res.status(404);
|
||||
throw new Error('Event option not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : eventOption.name,
|
||||
price: price !== undefined ? parseFloat(price) : eventOption.price,
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
||||
}
|
||||
});
|
||||
|
||||
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
||||
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
||||
// every tier for the option on every save, which silently reassigned each one a brand-new
|
||||
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
||||
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
||||
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
||||
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
||||
// ids stable across edits so that history stays linked.
|
||||
if (Array.isArray(earlyBirdTiers)) {
|
||||
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const et of existingTiers) {
|
||||
if (incomingIds.includes(et.id)) continue;
|
||||
// Never delete a tier that's still attributed on past purchases — just leave it
|
||||
// orphaned from the option's active tier list rather than nulling out history.
|
||||
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
||||
if (usageCount === 0) {
|
||||
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
||||
const t = 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 (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
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: require('uuid').v4(), ...data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If variants array provided, upsert variants
|
||||
if (Array.isArray(variants)) {
|
||||
const incomingIds = variants.filter(v => v.id).map(v => v.id);
|
||||
// Delete variants not in the new list (only if they have no registrations)
|
||||
const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const ev of existingVariants) {
|
||||
if (!incomingIds.includes(ev.id)) {
|
||||
const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } });
|
||||
if (usageCount === 0) {
|
||||
await prisma.optionVariant.delete({ where: { id: ev.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upsert incoming variants
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
const v = variants[i];
|
||||
if (!v.name) continue;
|
||||
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
|
||||
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
|
||||
const variantStaffPrice = parseStaffPrice(v.staffPrice);
|
||||
if (v.id) {
|
||||
await prisma.optionVariant.update({
|
||||
where: { id: v.id },
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
|
||||
});
|
||||
} else {
|
||||
await prisma.optionVariant.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
eventOptionId: updatedEventOption.id,
|
||||
name: v.name,
|
||||
price: variantPrice,
|
||||
stockLimit: variantStockLimit,
|
||||
staffPrice: variantStaffPrice,
|
||||
order: i,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stockLimit !== undefined || variants !== undefined) {
|
||||
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
||||
}
|
||||
|
||||
const refreshed = await prisma.eventOption.findUnique({
|
||||
where: { id: updatedEventOption.id },
|
||||
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
||||
});
|
||||
|
||||
res.json(refreshed);
|
||||
res.json(await details.updateOption(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -675,49 +317,10 @@ const updateEventOption = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const deleteEventOption = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!eventOption) {
|
||||
res.status(404);
|
||||
throw new Error('Event option not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
// If deleting a main ticket, ensure at least one other main ticket exists for the same event
|
||||
if (eventOption.isMainTicket) {
|
||||
const otherMainCount = await prisma.eventOption.count({
|
||||
where: {
|
||||
eventId: eventOption.eventId,
|
||||
isMainTicket: true,
|
||||
NOT: { id: eventOption.id },
|
||||
}
|
||||
});
|
||||
if (otherMainCount === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot delete the only main ticket option');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are any registrations using this option
|
||||
const registrationOptions = await prisma.registrationOption.findMany({
|
||||
where: { eventOptionId: req.params.id }
|
||||
});
|
||||
|
||||
if (registrationOptions.length > 0) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot delete event option that has registrations');
|
||||
}
|
||||
|
||||
await prisma.eventOption.delete({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
await details.deleteOption(req.params.id);
|
||||
res.json({ message: 'Event option removed' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -726,25 +329,9 @@ const deleteEventOption = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const createOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
|
||||
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
if (!name) { res.status(400); throw new Error('Variant name is required'); }
|
||||
const variant = await prisma.optionVariant.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventOptionId: req.params.id,
|
||||
name,
|
||||
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
order: order !== undefined ? parseInt(order, 10) : 0,
|
||||
}
|
||||
});
|
||||
res.status(201).json(variant);
|
||||
res.status(201).json(await details.createVariant(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -753,23 +340,9 @@ const createOptionVariant = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const updateOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
const updated = await prisma.optionVariant.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : variant.name,
|
||||
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
|
||||
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
|
||||
order: order !== undefined ? parseInt(order, 10) : variant.order,
|
||||
}
|
||||
});
|
||||
res.json(updated);
|
||||
res.json(await details.updateVariant(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -778,25 +351,19 @@ const updateOptionVariant = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const deleteOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } });
|
||||
if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); }
|
||||
await prisma.optionVariant.delete({ where: { id: req.params.id } });
|
||||
await details.deleteVariant(req.params.id);
|
||||
res.json({ message: 'Variant deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// File upload setup for attachments (PDFs/docs)
|
||||
// File upload for attachments (PDFs/docs), shared with /api/v2/events/{id}/attachments.
|
||||
const attachmentsStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
if (!fs.existsSync(details.ATTACHMENTS_DIR)) fs.mkdirSync(details.ATTACHMENTS_DIR, { recursive: true });
|
||||
cb(null, details.ATTACHMENTS_DIR);
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
@@ -805,48 +372,31 @@ const attachmentsStorage = multer.diskStorage({
|
||||
// Extension only — file.originalname is untrusted and joining it into a
|
||||
// path allows `../` traversal to write outside the upload directory.
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
cb(null, unique);
|
||||
cb(null, `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
|
||||
const uploadAttachmentMulter = multer({
|
||||
storage: attachmentsStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!allowedDocs.includes(ext)) {
|
||||
return cb(new Error('Unsupported file type'));
|
||||
}
|
||||
if (!details.ALLOWED_DOCS.includes(path.extname(file.originalname).toLowerCase())) return cb(new Error('Unsupported file type'));
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
// Saves the upload (field "file"); an error is kept on req for the handler to report.
|
||||
const receiveAttachment = (req, res, next) => uploadAttachmentMulter.single('file')(req, res, (e) => {
|
||||
if (e) req.multerError = e;
|
||||
next();
|
||||
});
|
||||
|
||||
// @desc List attachments for an event
|
||||
// @route GET /api/events/:id/attachments
|
||||
// @access Private/Supervisor (for dashboard) and could be public via event details
|
||||
const listEventAttachments = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
// If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (!fs.existsSync(manifestPath)) return res.json([]);
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const list = JSON.parse(raw);
|
||||
return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
||||
} catch (e) {
|
||||
// If manifest is corrupt or unreadable, return empty list rather than failing
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
|
||||
res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
||||
res.json(await details.listAttachments(req.params.id, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -854,97 +404,14 @@ const listEventAttachments = async (req, res) => {
|
||||
// @route POST /api/events/:id/attachments
|
||||
// @access Private/Supervisor
|
||||
const uploadEventAttachment = [
|
||||
(req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
}),
|
||||
receiveAttachment,
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (req.multerError) throw req.multerError;
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
|
||||
const eventId = req.params.id;
|
||||
const relUrl = `/uploads/event-files/${req.file.filename}`;
|
||||
|
||||
// Fallback: if attachments model is not available yet, store in filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) {
|
||||
try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {}
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
let list = [];
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
||||
} catch {}
|
||||
}
|
||||
list.unshift(entry);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Normal path: DB-backed
|
||||
try {
|
||||
const created = await prisma.eventAttachment.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
}
|
||||
});
|
||||
return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) });
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e || '');
|
||||
const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021');
|
||||
if (isMissingTable) {
|
||||
try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {}
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
let list = [];
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {}
|
||||
}
|
||||
list.unshift(entry);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
||||
} catch (fallbackErr) {
|
||||
return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) });
|
||||
}
|
||||
}
|
||||
// Unknown error
|
||||
throw e;
|
||||
}
|
||||
res.status(201).json(await details.recordAttachment(req.params.id, req.file, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -954,43 +421,10 @@ const uploadEventAttachment = [
|
||||
// @access Private/Admin or Supervisor
|
||||
const deleteEventAttachment = async (req, res) => {
|
||||
try {
|
||||
const { eventId, attachmentId } = req.params;
|
||||
// If attachments model is not available yet, fall back to filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' });
|
||||
const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
||||
const idx = list.findIndex((x) => x.id === attachmentId);
|
||||
if (idx === -1) return res.status(404).json({ message: 'Attachment not found' });
|
||||
const att = list[idx];
|
||||
// Remove file on disk (best-effort)
|
||||
try {
|
||||
const filePath = path.join(uploadDir, att.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
// Remove from manifest
|
||||
list.splice(idx, 1);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.json({ message: 'Attachment deleted' });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message });
|
||||
}
|
||||
}
|
||||
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
|
||||
if (!att || att.eventId !== eventId) {
|
||||
return res.status(404).json({ message: 'Attachment not found' });
|
||||
}
|
||||
// Remove file on disk (best-effort)
|
||||
try {
|
||||
const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
|
||||
await details.deleteAttachment(req.params.eventId, req.params.attachmentId);
|
||||
res.json({ message: 'Attachment deleted' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -999,52 +433,20 @@ const deleteEventAttachment = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const attachmentsStatus = async (req, res) => {
|
||||
try {
|
||||
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
||||
let tableQueryable = false;
|
||||
let probeError = null;
|
||||
if (hasModel) {
|
||||
try {
|
||||
await prisma.eventAttachment.findFirst({});
|
||||
tableQueryable = true;
|
||||
} catch (e) {
|
||||
probeError = String(e?.message || e);
|
||||
}
|
||||
}
|
||||
// Mask DB URL to avoid leaking secrets
|
||||
const dbUrl = process.env.DATABASE_URL || '';
|
||||
let dbInfo = null;
|
||||
try {
|
||||
const u = new URL(dbUrl);
|
||||
dbInfo = {
|
||||
protocol: u.protocol.replace(':',''),
|
||||
host: u.hostname,
|
||||
port: u.port || undefined,
|
||||
database: (u.pathname || '').replace(/^\//,'') || undefined,
|
||||
};
|
||||
} catch {}
|
||||
res.json({
|
||||
prismaClientHasEventAttachmentModel: hasModel,
|
||||
databaseTableQueryable: tableQueryable,
|
||||
probeError,
|
||||
database: dbInfo,
|
||||
});
|
||||
res.json(await details.attachmentsStatus());
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Admin-triggered sync: import manifest files into DB
|
||||
// @route POST /api/events/attachments/sync
|
||||
// @access Private/Admin
|
||||
const { syncManifestsToDb } = require('../utils/attachmentsSync');
|
||||
const attachmentsSync = async (req, res) => {
|
||||
try {
|
||||
const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true';
|
||||
const remove = String(req.query.remove || '').toLowerCase() === 'true';
|
||||
const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove });
|
||||
res.json({ dryRun, remove, ...summary });
|
||||
res.json(await details.syncAttachmentManifests({ dryRun: String(req.query.dryRun || '').toLowerCase() === 'true', remove: String(req.query.remove || '').toLowerCase() === 'true' }));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1635,58 +1037,17 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
|
||||
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
|
||||
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
const getEventByAlias = async (req, res) => {
|
||||
const { redirectUrl } = req.params;
|
||||
|
||||
if (!VALID_ALIAS.test(redirectUrl)) {
|
||||
return res.status(404).json({ message: 'Event not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await prisma.event.findFirst({
|
||||
where: {
|
||||
redirectUrl: {
|
||||
equals: redirectUrl,
|
||||
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 res.status(404).json({ message: 'Event not found or inactive' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = event.startDate ? new Date(event.startDate) : null;
|
||||
const end = event.endDate ? new Date(event.endDate) : null;
|
||||
const isLive =
|
||||
(!start || start >= now) &&
|
||||
(!end || end >= now);
|
||||
|
||||
res.json({ ...event, isLive });
|
||||
res.json(await details.eventByAlias(req.params.redirectUrl));
|
||||
} catch (error) {
|
||||
console.error('[getEventByAlias] Error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
receiveAttachment,
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
@@ -238,7 +238,9 @@ const createPayment = async (req, res) => {
|
||||
// If it's a registration payment, update registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
|
||||
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
|
||||
let formsNeeded = 0;
|
||||
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
@@ -284,6 +286,7 @@ const createPayment = async (req, res) => {
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
}
|
||||
formsNeeded = await formsOutstanding(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +321,7 @@ const createPayment = async (req, res) => {
|
||||
...updatedRegistration
|
||||
},
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
|
||||
formsNeeded,
|
||||
donationSplit: donationSplit || undefined
|
||||
};
|
||||
res.status(201).json(paymentWithUpdatedRegistration);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +1,24 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
// Season passes (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
|
||||
const series = require('../services/series');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { scopeFor, canManage } = require('../utils/campusScope');
|
||||
const passes = require('../utils/seasonPass');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const audit = (req, pass, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
|
||||
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
async function assertCampuses(req, campusIds) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && campusIds.some(c => !canManage(scope, c))) {
|
||||
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// @desc A series' passes with their events, shares, and how many were sold
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
// @access Private/Supervisor
|
||||
const listPasses = async (req, res) => {
|
||||
try {
|
||||
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
|
||||
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
res.json(rows.map(p => ({
|
||||
...passes.passView(p),
|
||||
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
|
||||
// @access Private/Supervisor
|
||||
const createPass = async (req, res) => {
|
||||
try {
|
||||
const seriesId = String(req.body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
|
||||
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
|
||||
await assertCampuses(req, items.map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({
|
||||
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
|
||||
});
|
||||
audit(req, pass, 'created', { events: items.length, price: pass.price });
|
||||
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Change a pass. Its events can only change while nobody has bought it.
|
||||
// @route PUT /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const updatePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
|
||||
}
|
||||
if (items) await assertCampuses(req, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
res.json(passes.passView(await passes.loadPass(current.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a pass nobody has bought (otherwise switch it off)
|
||||
// @route DELETE /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const deletePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
|
||||
}
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
// @access Private
|
||||
const buy = async (req, res) => {
|
||||
try {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
|
||||
const back = `${frontendUrl()}/series/${pass.series.slug}`;
|
||||
const result = await passes.buyPass(req.params.id, req.user.id, {
|
||||
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
|
||||
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Pay what's still owed on a purchase (a new checkout)
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
// @access Private (the buyer)
|
||||
const payPurchase = async (req, res) => {
|
||||
try {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
|
||||
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
|
||||
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
|
||||
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc The signed-in person's pass purchases
|
||||
// @route GET /api/season-passes/mine
|
||||
// @access Private
|
||||
const myPurchases = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.seasonPassPurchase.findMany({
|
||||
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
const listPasses = async (req, res) => { try { res.json(await series.listPasses(req.query.seriesId ? String(req.query.seriesId) : undefined)); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes
|
||||
const createPass = async (req, res) => { try { res.status(201).json(await series.createPass(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/season-passes/:id (events and price fixed once someone has bought it)
|
||||
const updatePass = async (req, res) => { try { res.json(await series.updatePass(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/season-passes/:id
|
||||
const deletePass = async (req, res) => { try { await series.deletePass(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
const buy = async (req, res) => { try { res.status(201).json(await series.buyPass(req.params.id, req.user.id, { quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
const payPurchase = async (req, res) => { try { res.json(await series.payPassPurchase(req.params.id, req.user.id, { provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/season-passes/mine
|
||||
const myPurchases = async (req, res) => { try { res.json(await series.myPassPurchases(req.user.id)); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
|
||||
|
||||
@@ -1,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 };
|
||||
|
||||
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Explicitly check existence as requested
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Email not found' });
|
||||
}
|
||||
|
||||
// Create token valid for 1 hour
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// If PasswordReset model isn't available (migration not run), return clear error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
|
||||
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
|
||||
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
|
||||
}
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
|
||||
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
|
||||
// Security (1.51.17): the same answer, with the same status, whether or not an account has
|
||||
// this email — and it's sent before any work that only happens for real accounts, so the
|
||||
// response time doesn't tell either. The link is made and sent afterwards.
|
||||
res.json({ message: GENERIC_RESET_MESSAGE });
|
||||
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
|
||||
|
||||
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
|
||||
async function sendPasswordResetLink(user) {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
}
|
||||
|
||||
// @desc Reset password using token
|
||||
// @route POST /api/users/reset
|
||||
// @access Public
|
||||
|
||||
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
|
||||
// @access Public
|
||||
const handleYocoWebhook = handleProviderWebhook('yoco');
|
||||
|
||||
const handleWhatsappWebhook = async (req, res) => {
|
||||
try {
|
||||
const { body } = req;
|
||||
const { message } = body;
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleYocoWebhook,
|
||||
handleWhatsappWebhook,
|
||||
};
|
||||
|
||||
@@ -25,10 +25,12 @@ function hubUrl() {
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
constructor(message, code, status, data = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
// The Hub's whole error body (AI errors carry the new AI status, protocol §3b).
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function read(keys) {
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
async function post(path, body, headers = {}, timeoutMs = TIMEOUT_MS) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
@@ -61,18 +63,21 @@ async function post(path, body, headers = {}) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
|
||||
async function signedPost(path, body) {
|
||||
/**
|
||||
* POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection.
|
||||
* AI calls wait longer than the default (Claude can take a while to write).
|
||||
*/
|
||||
async function signedPost(path, body, { timeoutMs = TIMEOUT_MS } = {}) {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
|
||||
const secret = decrypt(s.hub_install_secret);
|
||||
@@ -83,7 +88,7 @@ async function signedPost(path, body) {
|
||||
'X-Hub-Timestamp': timestamp,
|
||||
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
|
||||
};
|
||||
});
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
/** Register this install with a licence key. Returns the licence claims. */
|
||||
@@ -211,4 +216,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, signedPost, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -12,6 +12,7 @@ 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 }>;
|
||||
@@ -207,6 +208,10 @@ export type LoginResult =
|
||||
| { status: 'signed_in'; session: Session }
|
||||
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
|
||||
|
||||
let standIn: Promise<string> | null = null;
|
||||
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
|
||||
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000;
|
||||
|
||||
@@ -219,7 +224,12 @@ export async function logIn(input: { identifier: string; password: string; devic
|
||||
// SA local format (0xx) → international (27xx)
|
||||
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
|
||||
if (!user) return fail(401, 'User does not exist');
|
||||
// Security (1.51.17): an unknown email answers like a wrong password — same message and
|
||||
// status, and a password check against a stand-in hash so it takes as long.
|
||||
if (!user) {
|
||||
await auth.comparePassword(input.password, await standInHash());
|
||||
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
// Resend the activation link on each attempt, in case the earlier one expired.
|
||||
@@ -240,7 +250,9 @@ export async function logIn(input: { identifier: string; password: string; devic
|
||||
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
|
||||
// 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))) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Campuses (1.51.9): the public list and landing pages, Site settings → Campuses, and "everyone at
|
||||
* campus X" for messages. Moved from campusController.js so v1 and /api/v2 share it.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const campusesUtil = require('../utils/campuses') as {
|
||||
SETTING_KEY: string;
|
||||
CAMPUS_PUBLIC_SELECT: Record<string, true>;
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
cleanCampusInput(body: any, opts?: { partial?: boolean }): { data: Record<string, any>; error?: string };
|
||||
campusAudienceWhere(campusId: string, opts: { includeRegistrants: boolean }): object;
|
||||
};
|
||||
const settingsCache = require('../utils/settingsCache') as { invalidate(): void; warmCache(): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: { campusIds: string[] } | null; ip?: string };
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
} as const;
|
||||
|
||||
const audit = (actor: Actor, ctx: Ctx, action: string, campus: { id?: string; name?: string } | null, metadata: object) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** A taken web address → a message instead of a database error. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another campus already uses that web address — choose a different one.');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The campus could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether campuses are on, and the active ones in order (public filters, event cards). */
|
||||
export async function publicCampuses() {
|
||||
const enabled = await campusesUtil.campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** A campus landing page (active campuses, while campuses are on). */
|
||||
export async function campusBySlug(slug: string) {
|
||||
const campus = (await campusesUtil.campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(slug), archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
return campus;
|
||||
}
|
||||
|
||||
/** Every campus, archived ones last, with event counts and summary recipients. */
|
||||
export async function adminCampuses() {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesUtil.campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** Campuses on or off (off hides campus fields and filters; nothing is deleted). */
|
||||
export async function setCampusesEnabled(enabled: boolean, actor: Actor, ctx: Ctx) {
|
||||
await prisma.appSetting.upsert({ where: { key: campusesUtil.SETTING_KEY }, update: { value: String(enabled) }, create: { key: campusesUtil.SETTING_KEY, value: String(enabled) } });
|
||||
settingsCache.invalidate();
|
||||
await settingsCache.warmCache();
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: ctx.ip });
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
/** A new campus, at the end of the list. */
|
||||
export function createCampus(body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body);
|
||||
if (error) fail(400, error);
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...(data as any), order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(actor, ctx, 'campus_created', campus, { slug: campus.slug });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a campus: details, who gets its summaries (staff only), archived or not. */
|
||||
export function updateCampus(campusId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: campusId } });
|
||||
if (!existing) return fail(404, 'Campus not found');
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body, { partial: true });
|
||||
if (error) fail(400, error);
|
||||
if (Array.isArray(body?.notifyRecipientIds)) {
|
||||
const ids = body.notifyRecipientIds.filter((x: unknown) => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange: boolean | null = null;
|
||||
if (body?.archived !== undefined) {
|
||||
const archived = body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(actor, ctx, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(actor, ctx, 'campus_updated', campus, { changed });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts the campuses in this order (filters and lists follow it). */
|
||||
export async function reorderCampuses(ids: string[]) {
|
||||
const clean = (Array.isArray(ids) ? ids : []).filter(x => typeof x === 'string');
|
||||
if (!clean.length) fail(400, 'Send the campus ids in their new order.');
|
||||
await prisma.$transaction(clean.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
}
|
||||
|
||||
/** Deletes a campus with no events and no staff limited to it (otherwise archive it). */
|
||||
export async function deleteCampus(campusId: string, actor: Actor, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
fail(409, `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.`, 'CAMPUS_HAS_SCOPED_STAFF');
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
fail(409, `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.`, 'CAMPUS_HAS_EVENTS');
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(actor, ctx, 'campus_deleted', campus, { slug: campus.slug });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
* `includeRegistrants`) people registered for X's events in the last 12 months. News consent is
|
||||
* still checked when sending. Campus-scoped staff only get their own campuses.
|
||||
*/
|
||||
export async function campusAudience(campusId: string, includeRegistrants: boolean, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, select: { id: true, name: true } });
|
||||
if (!campus || (ctx.scope && !ctx.scope.campusIds.includes(campus.id))) return fail(404, 'Campus not found');
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusesUtil.campusAudienceWhere(campus.id, { includeRegistrants }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { campus, users };
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Corrections (1.52.0): putting registrations right when the normal flow didn't finish — tickets
|
||||
* held back by a required attendee form, tickets needed before it's paid, a balance to write off,
|
||||
* and check-ins after the event. Admins only, from Corrections; "Fill in later" (leaveFormForLater)
|
||||
* is for any staff at the door and in Manual registrations.
|
||||
*
|
||||
* Every change is in the audit log with who did it and the reason given.
|
||||
*/
|
||||
import { prisma as typedPrisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
|
||||
const prisma = typedPrisma as any;
|
||||
const { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers } = require('../utils/ticketUtils') as {
|
||||
generateTicketsForRegistration(id: string): Promise<any[]>;
|
||||
formsOutstanding(registration: string | object): Promise<number>;
|
||||
formPlaces(registrationOptions: any[]): number;
|
||||
formNeedsAnswers(form: any): boolean;
|
||||
};
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing') as { computeRegistrationTotalDue(registration: any, at: Date): number };
|
||||
const { recalculateRegistrationDiscount } = require('../utils/coupons') as { recalculateRegistrationDiscount(id: string): Promise<number> };
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
activeSessions(sessions: any[]): any[];
|
||||
sessionLabel(session: any, sessions: any[]): string;
|
||||
lineCovers(line: any, sessionId: string): boolean;
|
||||
lineSessions(line: any): string[] | null;
|
||||
redeemedForSession(usages: any[], sessionId: string | null): number;
|
||||
};
|
||||
|
||||
export type Actor = { id: string; role: string };
|
||||
type Audit = { actor: Actor; ip?: string };
|
||||
|
||||
/** The key a single-date event's check-ins are counted under. */
|
||||
export const WHOLE_EVENT = 'event';
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const realEmail = (email: string | null | undefined) => (email && !email.endsWith('@guest.local') ? email : null);
|
||||
|
||||
const REGISTRATION_INCLUDE = {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
formResponses: { select: { id: true } },
|
||||
attendees: { select: { id: true, name: true } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { name: true } },
|
||||
tranches: true,
|
||||
tickets: {
|
||||
include: { usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, holder: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function emailTicketsTo(registrationId: string, userId: string) {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
|
||||
const res: any = { status: () => res, json: () => {} };
|
||||
try {
|
||||
await emailTickets({ user: { id: userId }, body: { registrationId } }, res);
|
||||
} catch (e: any) {
|
||||
console.error('[corrections] Failed to send tickets:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvent(eventId: string) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, cashupStatus: true,
|
||||
sessions: { select: { id: true, label: true, startsAt: true, endsAt: true, cancelledAt: true } },
|
||||
form: { include: { fields: { select: { type: true } } } },
|
||||
},
|
||||
});
|
||||
return event || fail(404, 'Event not found');
|
||||
}
|
||||
|
||||
/** One registration as Corrections shows it: money, forms, tickets and their check-ins. */
|
||||
function correctionRow(reg: any, event: any, formRequired: boolean) {
|
||||
const now = new Date();
|
||||
const totalDue = round2(computeRegistrationTotalDue(reg, now));
|
||||
const paid = round2((reg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0));
|
||||
const active = sessions.activeSessions(event.sessions || []);
|
||||
const multi = event.scheduleType === 'sessions';
|
||||
let places = 0;
|
||||
let ticketPlaces = 0;
|
||||
const tickets: any[] = [];
|
||||
for (const ro of reg.registrationOptions || []) {
|
||||
places += ro.quantity || 0;
|
||||
const session = ro.sessionId ? active.find((s: any) => s.id === ro.sessionId) : null;
|
||||
const label = [ro.eventOption?.name, ro.variant?.name].filter(Boolean).join(' — ')
|
||||
+ (session ? ` (${sessions.sessionLabel(session, event.sessions)})` : '');
|
||||
for (const t of ro.tickets || []) {
|
||||
ticketPlaces += t.quantity || 0;
|
||||
const covered = multi ? (sessions.lineSessions(ro) ?? active.map((s: any) => s.id)).filter((id: string) => active.some((s: any) => s.id === id)) : [WHOLE_EVENT];
|
||||
const checkedIn: Record<string, number> = {};
|
||||
for (const key of covered) checkedIn[key] = sessions.redeemedForSession(t.usages, multi ? key : null);
|
||||
tickets.push({
|
||||
id: t.id,
|
||||
qrCode: t.qrCode,
|
||||
label,
|
||||
isMainTicket: !!ro.eventOption?.isMainTicket,
|
||||
name: t.attendee?.name || t.holder?.name || reg.user?.name || '',
|
||||
quantity: t.quantity || 1,
|
||||
sessionIds: covered,
|
||||
checkedIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ticketsDue = reg.status === 'paid' || !!reg.ticketsReleasedAt;
|
||||
const formsNeeded = formRequired && !reg.formWaivedAt ? Math.max(0, formPlaces(reg.registrationOptions) - (reg.formResponses || []).length) : 0;
|
||||
return {
|
||||
id: reg.id,
|
||||
status: reg.status,
|
||||
createdAt: reg.createdAt,
|
||||
user: { id: reg.user?.id, name: reg.user?.name || '', email: realEmail(reg.user?.email), phoneNumber: reg.user?.phoneNumber || null },
|
||||
totalDue,
|
||||
paid,
|
||||
balance: round2(Math.max(0, totalDue - paid)),
|
||||
places,
|
||||
ticketPlaces,
|
||||
missingTickets: ticketsDue && ticketPlaces < places,
|
||||
formsNeeded,
|
||||
formWaived: !!reg.formWaivedAt,
|
||||
ticketsReleased: !!reg.ticketsReleasedAt,
|
||||
complimentary: Number(reg.complimentaryDiscount || 0),
|
||||
tickets,
|
||||
};
|
||||
}
|
||||
|
||||
function eventSummary(event: any) {
|
||||
const active = sessions.activeSessions(event.sessions || []);
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
closed: event.cashupStatus === 'closed',
|
||||
formRequired: formNeedsAnswers(event.form),
|
||||
sessions: event.scheduleType === 'sessions'
|
||||
? active.map((s: any) => ({ id: s.id, label: sessions.sessionLabel(s, event.sessions), startsAt: s.startsAt }))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** An event's registrations (not cancelled) with what may need putting right. */
|
||||
export async function eventCorrections(eventId: string) {
|
||||
const event = await loadEvent(eventId);
|
||||
const formRequired = formNeedsAnswers(event.form);
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId, status: { not: 'cancelled' } },
|
||||
include: REGISTRATION_INCLUDE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return { event: eventSummary(event), registrations: regs.map((r: any) => correctionRow(r, event, formRequired)) };
|
||||
}
|
||||
|
||||
async function registrationRow(registrationId: string) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const event = await loadEvent(reg.eventId);
|
||||
return correctionRow(reg, event, formNeedsAnswers(event.form));
|
||||
}
|
||||
|
||||
async function activeRegistration(registrationId: string) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
if (reg.status === 'cancelled') return fail(400, 'This registration is cancelled.');
|
||||
return reg;
|
||||
}
|
||||
|
||||
async function waiveForm(reg: any, actorId: string) {
|
||||
if (reg.formWaivedAt || (await formsOutstanding(reg)) === 0) return false;
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { formWaivedAt: new Date(), formWaivedById: actorId, updatedAt: new Date() } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fill in later" at the door / in Manual registrations: the tickets stop waiting for the
|
||||
* attendee form, and the payer is sent a link to fill it in (`notify`).
|
||||
*/
|
||||
export async function leaveFormForLater(registrationId: string, { actor, ip }: Audit, { notify = true }: { notify?: boolean } = {}) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
const waived = await waiveForm(reg, actor.id);
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
if (created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
let sentTo: string[] = [];
|
||||
if (waived) {
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'door' }, ip });
|
||||
if (notify) {
|
||||
const { sendFormReminderMessage } = require('../utils/notifications') as { sendFormReminderMessage(id: string): Promise<string[]> };
|
||||
sentTo = await sendFormReminderMessage(reg.id).catch(() => []);
|
||||
}
|
||||
}
|
||||
return { ticketsIssued: created.length, sentTo };
|
||||
}
|
||||
|
||||
type IssueOptions = { skipForm?: boolean; payLater?: boolean; send?: boolean; reason?: string | null };
|
||||
|
||||
/**
|
||||
* Admin: issue a registration's tickets now — leaving the attendee form for later (`skipForm`)
|
||||
* and/or before it's paid (`payLater`, the balance is still owed).
|
||||
*/
|
||||
export async function issueTickets(registrationId: string, { actor, ip }: Audit, opts: IssueOptions) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
if (reg.status !== 'paid' && !reg.ticketsReleasedAt && !opts.payLater) {
|
||||
return fail(400, 'This registration isn\'t paid yet. Choose "pay later" or "make it free" to issue its tickets.');
|
||||
}
|
||||
const outstanding = opts.skipForm ? 0 : await formsOutstanding(reg);
|
||||
if (outstanding > 0) {
|
||||
return fail(400, `${outstanding} attendee form${outstanding === 1 ? ' is' : 's are'} still to fill in. Fill ${outstanding === 1 ? 'it' : 'them'} in, or choose to leave the form for later.`);
|
||||
}
|
||||
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
|
||||
const released = !!opts.payLater && reg.status !== 'paid' && !reg.ticketsReleasedAt;
|
||||
if (released) {
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { ticketsReleasedAt: new Date(), ticketsReleasedById: actor.id, updatedAt: new Date() } });
|
||||
}
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Registration', targetId: reg.id,
|
||||
metadata: { eventId: reg.eventId, formLeftForLater: waived, payLater: released, ticketsCreated: created.length, reason: opts.reason || null },
|
||||
ip,
|
||||
});
|
||||
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
|
||||
return { ticketsIssued: created.length, registration: await registrationRow(reg.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: write off what's still owed ("make it free") — it comes off as a complimentary
|
||||
* discount, so income stays what was really paid — and issue the tickets.
|
||||
*/
|
||||
export async function makeComplimentary(registrationId: string, { actor, ip }: Audit, opts: Omit<IssueOptions, 'payLater'>) {
|
||||
const reg = await activeRegistration(registrationId);
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
return fail(400, e?.message || 'This event is closed.');
|
||||
}
|
||||
const full = await prisma.registration.findUnique({
|
||||
where: { id: reg.id },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const due = computeRegistrationTotalDue(full, new Date());
|
||||
const paid = (full.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const balance = round2(due - paid);
|
||||
if (!(balance > 0.004)) return fail(400, 'Nothing is owed on this registration.');
|
||||
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { complimentaryDiscount: round2(Number(full.complimentaryDiscount || 0) + balance), updatedAt: new Date() } });
|
||||
await recalculateRegistrationDiscount(reg.id);
|
||||
await prisma.registration.update({ where: { id: reg.id }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_complimentary', targetType: 'Registration', targetId: reg.id,
|
||||
metadata: { eventId: reg.eventId, amount: balance, reason: opts.reason || null }, ip,
|
||||
});
|
||||
|
||||
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
|
||||
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
return { amount: balance, ticketsIssued: created.length, registration: await registrationRow(reg.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: every paid registration (or one released unpaid) at an event that's missing tickets
|
||||
* gets them — forms still outstanding are left for later.
|
||||
*/
|
||||
export async function issueMissingTickets(eventId: string, { actor, ip }: Audit, { send = false }: { send?: boolean } = {}) {
|
||||
const { registrations } = await eventCorrections(eventId);
|
||||
const missing = registrations.filter((r: any) => r.missingTickets);
|
||||
let tickets = 0;
|
||||
let fixed = 0;
|
||||
const failed: { id: string; name: string; message: string }[] = [];
|
||||
for (const row of missing) {
|
||||
try {
|
||||
const reg = await activeRegistration(row.id);
|
||||
await waiveForm(reg, actor.id);
|
||||
const created = await generateTicketsForRegistration(reg.id);
|
||||
tickets += created.length;
|
||||
if (created.length > 0) fixed += 1;
|
||||
if (send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
|
||||
} catch (e: any) {
|
||||
failed.push({ id: row.id, name: row.user.name, message: e?.message || 'Failed' });
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Event', targetId: eventId,
|
||||
metadata: { registrations: fixed, ticketsCreated: tickets, formLeftForLater: true, sent: send, failed: failed.length }, ip,
|
||||
});
|
||||
}
|
||||
return { registrations: fixed, tickets, failed };
|
||||
}
|
||||
|
||||
type CheckInOptions = { sessionId?: string | null; people?: number | null; present?: boolean; reason?: string | null };
|
||||
|
||||
/**
|
||||
* Admin: check a ticket in (or undo it) whatever the time — after the event or session has
|
||||
* ended, outside the check-in window, or once the event is closed. Recorded as a manual
|
||||
* check-in; no check-in message is sent.
|
||||
*/
|
||||
export async function correctCheckIn(ticketId: string, { actor, ip }: Audit, opts: CheckInOptions) {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: { usages: true, event: { select: { id: true, scheduleType: true } }, registrationOption: { select: { sessionId: true, sessionIds: true, registration: { select: { status: true } } } } },
|
||||
});
|
||||
if (!ticket) return fail(404, 'Ticket not found');
|
||||
if (ticket.registrationOption?.registration?.status === 'cancelled') return fail(400, 'This registration is cancelled.');
|
||||
const multi = ticket.event.scheduleType === 'sessions';
|
||||
let sessionId: string | null = null;
|
||||
if (multi) {
|
||||
const session = opts.sessionId ? await prisma.eventSession.findUnique({ where: { id: opts.sessionId } }) : null;
|
||||
if (!session || session.eventId !== ticket.eventId) return fail(400, 'Choose one of this event\'s sessions.');
|
||||
if (session.cancelledAt) return fail(400, 'That session was cancelled.');
|
||||
if (ticket.registrationOption && !sessions.lineCovers(ticket.registrationOption, session.id)) return fail(400, 'That ticket isn\'t booked for this session.');
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
const forSession = ticket.usages.filter((u: any) => (u.sessionId || null) === sessionId);
|
||||
const redeemed = sessions.redeemedForSession(ticket.usages, sessionId);
|
||||
const quantity = ticket.quantity || 1;
|
||||
let changed = 0;
|
||||
if (opts.present === false) {
|
||||
if (forSession.length === 0) return fail(400, 'This ticket isn\'t checked in.');
|
||||
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map((u: any) => u.id) } } });
|
||||
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
|
||||
changed = redeemed;
|
||||
} else {
|
||||
const left = quantity - redeemed;
|
||||
if (left <= 0) return fail(400, 'Everyone on this ticket is already checked in.');
|
||||
const people = Math.min(Math.max(1, Math.trunc(Number(opts.people)) || left), left);
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: people, sessionId, manual: true } });
|
||||
if (!multi && redeemed + people >= quantity) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
|
||||
changed = people;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'checkin_corrected', targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { eventId: ticket.eventId, sessionId, present: opts.present !== false, people: changed, reason: opts.reason || null }, ip,
|
||||
});
|
||||
const after = await prisma.ticketUsage.findMany({ where: { ticketId: ticket.id }, select: { quantityRedeemed: true, sessionId: true } });
|
||||
return { checkedIn: sessions.redeemedForSession(after, sessionId), quantity };
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Bundle deals and coupon codes (1.51.9). Moved from bundleController.js and couponController.js
|
||||
* so v1 and /api/v2 share them; matching codes and working out discounts stays in
|
||||
* utils/coupons.js and utils/bundles.js.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const bundlesUtil = require('../utils/bundles') as {
|
||||
KINDS: string[];
|
||||
describeBundle(b: any): string;
|
||||
describeBundleItems(b: any): string;
|
||||
isBundleAvailable(b: any, now: Date): boolean;
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
normalizeCode(code: unknown): string;
|
||||
previewCoupon(args: object): Promise<{ coupon: any; discount: number; bundles: { discount: number; applications: any[] } }>;
|
||||
previewBundles(eventId: string, lines: any[]): Promise<{ discount: number; applications: any[] }>;
|
||||
buildCouponLines(reg: any): any[];
|
||||
describeCoupon(c: any): string;
|
||||
countCouponUses(couponId: string, userId: string | null): Promise<{ usesTotal: number }>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as { couponAllowed(scope: object | null, campusIds: Array<string | null>): boolean };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role; email?: string };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
const round2 = (n: unknown) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n: number) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** Plain errors from the parsers and helpers are the caller's mistake (400), like v1. */
|
||||
async function clientErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'That could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const optionalDate = (v: unknown, label: string): Date | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(String(v));
|
||||
if (isNaN(d.getTime())) fail(400, `${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
// ─── Pricing a basket (bundle quotes and coupon checks) ──────────────────────
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the signed-in buyer, or — for staff — the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
export async function basketBuyer(actor: Actor | null, forUserId: string | null | undefined) {
|
||||
const isStaff = !!(actor && STAFF_ROLES.includes(actor.role));
|
||||
let buyer: { id: string; role: string; email?: string } | null = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
if (isStaff && forUserId) buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
else if (isStaff && forUserId === null) buyer = null;
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
export type BasketLine = { eventOptionId: string; variantId?: string | null; quantity: number; sessionIds?: string[] };
|
||||
|
||||
/** Prices a basket the way registering will, as coupon/bundle lines. */
|
||||
export async function priceBasket(event: { eventOptions: any[] } & Record<string, any>, options: BasketLine[], buyer: any) {
|
||||
const registrationOptions: any[] = [];
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const { isPickOption } = require('../utils/sessions') as { isPickOption(ev: any, opt: any): boolean };
|
||||
const perSession = (o: BasketLine) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId ? await pricing.resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await pricing.resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }] });
|
||||
}
|
||||
return coupons.buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
const basketEvent = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** The bundle deals a basket gets, before any coupon. */
|
||||
export function quoteBundles(input: { eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null) {
|
||||
return clientErrors(async () => {
|
||||
if (!input.eventId || !Array.isArray(input.options)) fail(400, 'eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = round2(lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0));
|
||||
const b = await coupons.previewBundles(event.id, lines);
|
||||
return { subtotal, bundleDiscount: b.discount, bundles: b.applications, total: round2(Math.max(0, subtotal - b.discount)) };
|
||||
});
|
||||
}
|
||||
|
||||
export type CodeCheck =
|
||||
| { valid: true; code: string; type: string; value: number; summary: string; description: string | null; discount: number; bundleDiscount: number; bundles: any[]; subtotal: number; total: number }
|
||||
| { valid: false; message: string };
|
||||
|
||||
/**
|
||||
* Checks a code against a basket. Staff can check on an attendee's behalf (`forUserId`), or
|
||||
* leave it out for a new attendee — restricted codes are then checked when they register.
|
||||
*/
|
||||
export function checkCode(input: { code?: string; eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null): Promise<CodeCheck> {
|
||||
return clientErrors(async () => {
|
||||
if (!input.code || !input.eventId || !Array.isArray(input.options) || input.options.length === 0) fail(400, 'code, eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer, isStaff } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0);
|
||||
try {
|
||||
const { coupon, discount, bundles } = await coupons.previewCoupon({ code: input.code, eventId: event.id, user: buyer, lines, deferRestrictions: isStaff && !buyer });
|
||||
return {
|
||||
valid: true as const, code: coupon.code, type: coupon.type, value: coupon.value, summary: coupons.describeCoupon(coupon), description: coupon.description,
|
||||
discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (!e.isCouponError) throw e;
|
||||
return { valid: false as const, message: e.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i: any) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find((v: any) => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
|
||||
/** What the deal is worth at standard prices, and a short "save …" line (or null when the headline says it). */
|
||||
function valueOf(bundle: any): { standardValue: number | null; savingText: string | null } {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m: number, i: any) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s: number, i: any) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
function shapeBundle(bundle: any, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }: any) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return { ...bundle, items, summary: bundlesUtil.describeBundleItems(bundle), headline: bundlesUtil.describeBundle(bundle), ...valueOf(bundle), available: bundlesUtil.isBundleAvailable(bundle, now) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A create/update body as Prisma data plus the item list. On update, fields left out stay as
|
||||
* they are; when any pricing field is sent, the whole deal (saved + new values) is checked.
|
||||
*/
|
||||
async function parseBundleBody(body: any, eventId: string, { isCreate, current = null }: { isCreate: boolean; current?: any }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) fail(400, 'Give the bundle a name');
|
||||
if (name.length > 80) fail(400, 'Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Available from" must be before "Available until"');
|
||||
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items: undefined };
|
||||
|
||||
const pick = (f: string) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!bundlesUtil.KINDS.includes(kind)) fail(400, 'Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map<string, { eventOptionId: string; variantId: string | null; quantity: number }>();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) fail(400, 'Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
const items = [...merged.values()];
|
||||
if (items.length === 0) fail(400, kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) fail(400, 'A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) fail(400, 'At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) fail(400, 'Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) fail(400, 'Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) fail(400, 'Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) fail(400, 'Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: items.map(i => i.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) fail(400, 'One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row!.variants.some(v => v.id === i.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
const eventOr404 = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** An event's bundle deals with how often each has been used and the discount given. */
|
||||
export async function listBundles(eventId: string) {
|
||||
await eventOr404(eventId);
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
return bundles.map(b => ({
|
||||
...shapeBundle(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createBundle(eventId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const event = await eventOr404(eventId);
|
||||
const parsed = await parseBundleBody(body || {}, event.id, { isCreate: true });
|
||||
const bundle = await prisma.eventBundle.create({ data: { ...(parsed.data as any), eventId: event.id, items: { create: parsed.items } }, include: DETAIL_INCLUDE });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_created', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: bundlesUtil.describeBundle(bundle) }, ip: ctx.ip,
|
||||
});
|
||||
return { ...shapeBundle(bundle), uses: 0, registrations: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Edits a deal (or switches it off). Registrations that have it are re-worked only when their own items change. */
|
||||
export function updateBundle(bundleId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId }, include: { items: true } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const parsed = await parseBundleBody(body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_updated', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(body || {}) }, ip: ctx.ip,
|
||||
});
|
||||
return shapeBundle(bundle);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a deal; one that's been used is switched off instead, so registrations keep their price. */
|
||||
export async function deleteBundle(bundleId: string, actor: Actor, ctx: Ctx) {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
else await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_deleted', targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 }, ip: ctx.ip,
|
||||
});
|
||||
return used > 0
|
||||
? { switchedOff: true as const, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true as const, message: 'Bundle deleted' };
|
||||
}
|
||||
|
||||
/** The deals shown on an event's page: available bundles, without internal fields. */
|
||||
export async function publicBundlesForEvent(eventId: string) {
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId, isActive: true }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const now = new Date();
|
||||
return bundles.filter(b => bundlesUtil.isBundleAvailable(b, now)).map(b => {
|
||||
const s = shapeBundle(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map((i: any) => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses.
|
||||
const couponCampuses = (coupon: any): Array<string | null> => (coupon.events || []).map((ce: any) => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope: object | null, eventIds: string[] | undefined) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && campusScope.couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
|
||||
const optionalInt = (v: unknown, label: string, min = 0): number | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) fail(400, `${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** A create/update body as Prisma data plus the event, ticket and person lists. On update, fields left out stay. */
|
||||
async function parseCouponBody(body: any, { isCreate }: { isCreate: boolean }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = coupons.normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) fail(400, 'Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) fail(400, "Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) fail(400, 'Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) fail(400, 'A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some((r: string) => !ROLES.includes(r))) fail(400, 'Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) fail(400, 'Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
const eventIds: string[] | undefined = body.eventIds !== undefined ? [...new Set<string>((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes: Array<{ eventOptionId: string; variantId: string | null }> | undefined = body.options !== undefined
|
||||
? (body.options || []).filter((o: any) => o && o.eventOptionId).map((o: any) => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds: string[] | undefined = body.userIds !== undefined ? [...new Set<string>((body.userIds || []).filter(Boolean))] : undefined;
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) return fail(400, 'One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) fail(400, 'Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }: Awaited<ReturnType<typeof parseCouponBody>>, isCreate: boolean) {
|
||||
const out: Record<string, any> = {};
|
||||
const replace = isCreate ? {} : { deleteMany: {} };
|
||||
if (eventIds !== undefined) out.events = { ...replace, create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...replace, create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...replace, create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Codes the caller may see, with how often each has been used and the discount given. */
|
||||
export async function listCoupons(ctx: Ctx) {
|
||||
const list = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } })).filter(c => campusScope.couponAllowed(ctx.scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
return list.map(c => ({ ...c, summary: coupons.describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0 }));
|
||||
}
|
||||
|
||||
/** One code with its redemptions. */
|
||||
export async function getCoupon(couponId: string, ctx: Ctx) {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: couponId },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !campusScope.couponAllowed(ctx.scope, couponCampuses(coupon))) return fail(404, 'Coupon not found');
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon) };
|
||||
}
|
||||
|
||||
export function createCoupon(body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const parsed = await parseCouponBody(body || {}, { isCreate: true });
|
||||
if (!(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
const coupon = await prisma.coupon.create({ data: { ...(parsed.data as any), createdById: actor.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE });
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: ctx.ip });
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a code (or turns it off). Discounts already applied keep their locked amount; a used code can't be renamed. */
|
||||
export function updateCoupon(couponId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: couponId }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
if (!existing || !campusScope.couponAllowed(ctx.scope, couponCampuses(existing))) return fail(404, 'Coupon not found');
|
||||
const parsed = await parseCouponBody({ type: existing.type, ...body }, { isCreate: false });
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
if (await prisma.couponRedemption.count({ where: { couponId: existing.id } }) > 0) fail(400, 'This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.');
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE });
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(body || {});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: ctx.ip,
|
||||
});
|
||||
const { usesTotal } = await coupons.countCouponUses(coupon.id, null);
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: usesTotal };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and bundle-deal uses for the Discounts report, newest first. `scopeWhere` limits it to
|
||||
* the caller's campuses (`{ event: … }` or `{}`). Bundle dates go by when the registration was made.
|
||||
*/
|
||||
export async function listRedemptions(opts: { eventIds: string[]; from: Date | null; to: Date | null }, scopeWhere: object) {
|
||||
const range = (opts.from || opts.to) ? { createdAt: { ...(opts.from ? { gte: opts.from } : {}), ...(opts.to ? { lte: opts.to } : {}) } } : {};
|
||||
const events = opts.eventIds.length ? { eventId: { in: opts.eventIds } } : {};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' }, ...events, ...scopeWhere }, ...range },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...events, ...range, ...scopeWhere } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
return [
|
||||
...rows.map(r => ({ ...r, kind: 'coupon' as const, uses: 1, summary: coupons.describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id, kind: 'bundle' as const, uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: bundlesUtil.describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -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 })) };
|
||||
}
|
||||
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. */
|
||||
|
||||
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
|
||||
|
||||
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
|
||||
function oneClickUrl(target) {
|
||||
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
|
||||
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
|
||||
}
|
||||
|
||||
/** What an unsubscribe link refers to, for the landing page. */
|
||||
@@ -469,6 +469,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
// AI helper calls (2.0): kept for the site's monthly totals, no longer linked to them.
|
||||
await tx.aiCall.updateMany({ where: { userId }, data: { userId: null } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• If we use the AI helpers: our staff can ask an AI service to help draft event details and messages, improve text, and summarise reports and event feedback. A person checks every suggestion before anything is saved or sent. Only what a task needs is sent — never your name, contact details, or medical or children\'s information — and feedback comments are sent without who wrote them (see section 9).',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
'• Improve our events and website (using anonymous statistics, and only with your permission).',
|
||||
'• Comply with legal obligations.',
|
||||
@@ -166,6 +167,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we use the AI helpers, requests go through our software provider\'s service to Anthropic (the Claude AI service) in the United States, which is listed above. Our software provider does not keep what was sent. Anthropic handles it under its commercial terms, which do not allow it to be used to train AI; it may be kept for a short time for safety checks. Because this is outside South Africa, it is only done under the protections POPIA requires for such transfers.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
@@ -279,6 +281,14 @@ const BUILT_IN_UPDATES = [
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
{
|
||||
revision: '2.0.0',
|
||||
summary: [
|
||||
'AI helpers: staff can ask an AI service (Anthropic\'s Claude, through the software provider) to draft events and messages, improve text and summarise reports and feedback; a person checks every suggestion, and no names, contact details, medical or children\'s information are sent.',
|
||||
'Where AI requests go (the United States), that the provider doesn\'t keep them, and that they aren\'t used to train AI.',
|
||||
],
|
||||
sections: ['use', 'sharing'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
@@ -2,8 +2,55 @@ const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { planLineTickets } = require('./attendees');
|
||||
|
||||
/** Form fields that are only text to read (a statement, a heading). */
|
||||
const DISPLAY_FIELDS = ['statement', 'paragraph'];
|
||||
|
||||
/** A required form with at least one question to answer — the only kind tickets wait for. */
|
||||
const formNeedsAnswers = (form) => !!(form && form.isRequired && (form.fields || []).some(f => !DISPLAY_FIELDS.includes(f.type)));
|
||||
|
||||
/**
|
||||
* Generate tickets for a registration when payment status is "paid".
|
||||
* How many attendee forms a registration's places need: one per main-ticket place. A picked-
|
||||
* session booking has a line per session for the same people, so it counts once (its biggest
|
||||
* line), not once per session.
|
||||
*/
|
||||
function formPlaces(registrationOptions) {
|
||||
let total = 0;
|
||||
const picked = new Map();
|
||||
for (const ro of registrationOptions || []) {
|
||||
if (!ro.eventOption?.isMainTicket) continue;
|
||||
const qty = ro.quantity || 0;
|
||||
if (!ro.sessionId) { total += qty; continue; }
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
picked.set(key, Math.max(picked.get(key) || 0, qty));
|
||||
}
|
||||
for (const qty of picked.values()) total += qty;
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attendee forms a registration still needs before its tickets can be issued: 0 when the
|
||||
* event's form isn't required (or has no questions), when the form was left for later
|
||||
* (`formWaivedAt`), or when enough are in. `registration` is an id or a record with
|
||||
* registrationOptions[].eventOption.
|
||||
*/
|
||||
async function formsOutstanding(registration) {
|
||||
try {
|
||||
const reg = typeof registration === 'string'
|
||||
? await prisma.registration.findUnique({ where: { id: registration }, include: { registrationOptions: { include: { eventOption: true } } } })
|
||||
: registration;
|
||||
if (!reg || reg.formWaivedAt) return 0;
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId }, include: { fields: true } });
|
||||
if (!formNeedsAnswers(form)) return 0;
|
||||
const submitted = await prisma.formResponse.count({ where: { registrationId: reg.id } });
|
||||
return Math.max(0, formPlaces(reg.registrationOptions) - submitted);
|
||||
} catch (_) {
|
||||
return 0; // form models unavailable — don't block
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tickets for a registration when payment status is "paid" (or an admin released
|
||||
* them unpaid) and no required attendee form is outstanding (see formsOutstanding).
|
||||
*
|
||||
* Rules:
|
||||
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
|
||||
@@ -34,19 +81,11 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
});
|
||||
|
||||
if (!registration) throw new Error('Registration not found');
|
||||
if (registration.status !== 'paid') return [];
|
||||
// Paid, or an admin issued the tickets before payment ("pay later", 1.52.0).
|
||||
if (registration.status !== 'paid' && !(registration.ticketsReleasedAt && registration.status !== 'cancelled')) return [];
|
||||
|
||||
// If event has a required form, ensure sufficient responses exist
|
||||
try {
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
||||
if (form && form.isRequired) {
|
||||
const requiredCount = (registration.registrationOptions || [])
|
||||
.filter(ro => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
|
||||
if (responsesCount < requiredCount) return [];
|
||||
}
|
||||
} catch (_) { /* form models unavailable — don't block */ }
|
||||
// A required attendee form comes first, unless it's been left for later.
|
||||
if ((await formsOutstanding(registration)) > 0) return [];
|
||||
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
|
||||
// Key on both fields so that different variants of the same option are never merged
|
||||
@@ -133,4 +172,4 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { generateTicketsForRegistration };
|
||||
module.exports = { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers, DISPLAY_FIELDS };
|
||||
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
|
||||
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
|
||||
async function webhookUrl() {
|
||||
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
|
||||
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
|
||||
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
|
||||
}
|
||||
|
||||
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
|
||||
|
||||
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
|
||||
*/
|
||||
function defaultWebhookUrl(req) {
|
||||
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
|
||||
return base ? `${base}/api/webhooks/yoco` : '';
|
||||
return base ? `${base}/api/v2/webhooks/yoco` : '';
|
||||
}
|
||||
|
||||
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
|
||||
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
|
||||
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
|
||||
*/
|
||||
|
||||
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
|
||||
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
|
||||
|
||||
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
|
||||
|
||||
const SUCCESSORS: Successor[] = [
|
||||
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
// 1.51.7: setting events up
|
||||
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
];
|
||||
|
||||
// GET /api/events/all isn't a single event.
|
||||
const NOT_AN_EVENT_ID = new Set(['all']);
|
||||
|
||||
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
|
||||
export function successorOf(method: string, path: string): string | null {
|
||||
for (const s of SUCCESSORS) {
|
||||
if (s.method !== method) continue;
|
||||
const m = path.match(s.v1);
|
||||
if (!m) continue;
|
||||
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
|
||||
return s.v2(m);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const deprecationHeaders: RequestHandler = (req, res, next) => {
|
||||
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
|
||||
if (successor) {
|
||||
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
|
||||
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Shapes for the API documentation (1.52.1): the records v1 handlers answer with, and the bodies
|
||||
* they take, described once so every route shows the same thing the same way. Used as
|
||||
* `documented: { body, response }` on v1Route (see RouteDef.documented) — shown in the OpenAPI
|
||||
* document, not enforced; outside production anything that doesn't fit is logged.
|
||||
*
|
||||
* Everything is a loose object (extra fields pass, as v1 answers carry their Prisma includes),
|
||||
* dates are ISO strings (as they arrive in JSON), and fields a handler may leave out are optional.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export const obj = <T extends z.ZodRawShape>(shape: T) => z.looseObject(shape);
|
||||
export const str = z.string();
|
||||
export const optStr = z.string().nullish();
|
||||
export const num = z.number();
|
||||
export const optNum = z.number().nullish();
|
||||
export const bool = z.boolean();
|
||||
export const optBool = z.boolean().nullish();
|
||||
export const date = z.string().describe('Date and time (ISO 8601)');
|
||||
export const optDate = date.nullish();
|
||||
export const rand = z.number().describe('Rand');
|
||||
export const optRand = rand.nullish();
|
||||
export const anyObj = z.looseObject({});
|
||||
/** A number sent as a number or as text (forms send text). */
|
||||
export const numeric = z.union([z.number(), z.string()]);
|
||||
export const ids = z.array(z.string()).describe('Ids');
|
||||
export const channel = z.enum(['email', 'whatsapp', 'both']).describe('How to send it');
|
||||
|
||||
/** `{ message }`: what was done, in words for the person. */
|
||||
export const message = obj({ message: str });
|
||||
/** `{ ok: true }` */
|
||||
export const ok = obj({ ok: z.literal(true) });
|
||||
/** v1's paged lists: `{ data, total, page, limit, pages }`. */
|
||||
export const v1Page = <T extends z.ZodType>(item: T) => obj({ data: z.array(item), total: num, page: num, limit: num, pages: num });
|
||||
/** A `{ redirectUrl }` answer from a checkout: where to send the person to pay. */
|
||||
export const checkout = obj({
|
||||
checkoutId: optStr,
|
||||
redirectUrl: optStr.describe('Where to send the person to pay (null when the provider takes a form)'),
|
||||
provider: optStr,
|
||||
form: obj({ action: str, fields: z.record(z.string(), z.string()) }).nullish().describe('A form to post (PayFast)'),
|
||||
});
|
||||
|
||||
export const userSummary = obj({ id: str.optional(), name: str, email: optStr, phoneNumber: optStr });
|
||||
export const userRef = obj({ id: str.optional(), name: str, email: optStr });
|
||||
|
||||
export const user = obj({
|
||||
id: str,
|
||||
name: str,
|
||||
email: str,
|
||||
phoneNumber: optStr,
|
||||
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
|
||||
isActive: optBool,
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']).nullish(),
|
||||
homeCampusId: optStr,
|
||||
createdAt: optDate,
|
||||
});
|
||||
|
||||
export const eventRef = obj({ id: str, title: str, startDate: optDate, endDate: optDate });
|
||||
|
||||
export const event = obj({
|
||||
id: str,
|
||||
title: str,
|
||||
description: optStr,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
location: optStr,
|
||||
price: optRand,
|
||||
isActive: optBool,
|
||||
isHidden: optBool,
|
||||
imageUrl: optStr,
|
||||
campusId: optStr,
|
||||
scheduleType: z.enum(['single', 'sessions']).nullish(),
|
||||
cashupStatus: z.enum(['open', 'closed']).nullish(),
|
||||
});
|
||||
|
||||
export const session = obj({ id: str, label: optStr, startsAt: date, endsAt: date, cancelledAt: optDate, capacity: optNum });
|
||||
|
||||
export const eventOption = obj({
|
||||
id: str,
|
||||
eventId: str.optional(),
|
||||
name: str,
|
||||
description: optStr,
|
||||
price: rand,
|
||||
isMainTicket: bool,
|
||||
stockLimit: optNum,
|
||||
});
|
||||
|
||||
export const payment = obj({
|
||||
id: str,
|
||||
amount: rand.describe('Rand; a refund is negative'),
|
||||
method: str.describe('cash, card, eft, other — or the wallet type for an online payment'),
|
||||
provider: optStr.describe('yoco or payfast for an online payment'),
|
||||
userId: str,
|
||||
recordedById: optStr,
|
||||
registrationId: optStr,
|
||||
eventId: optStr,
|
||||
isDonation: bool,
|
||||
isSponsorship: optBool,
|
||||
originalPaymentId: optStr.describe('A refund: the payment it refunds; a donation leg: the donation'),
|
||||
externalId: optStr,
|
||||
createdAt: date,
|
||||
user: userSummary.nullish(),
|
||||
recordedBy: userRef.nullish(),
|
||||
event: eventRef.nullish(),
|
||||
});
|
||||
|
||||
export const registrationStatus = z.enum(['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled']);
|
||||
|
||||
export const registration = obj({
|
||||
id: str,
|
||||
userId: str,
|
||||
eventId: str,
|
||||
status: registrationStatus,
|
||||
discountTotal: optRand,
|
||||
totalDueComputed: optRand.describe('What it costs now, after discounts'),
|
||||
createdAt: date,
|
||||
user: userSummary.nullish(),
|
||||
event: eventRef.nullish(),
|
||||
});
|
||||
|
||||
export const ticket = obj({
|
||||
id: str,
|
||||
qrCode: str.describe('What the QR code holds'),
|
||||
quantity: num.describe('People on the ticket'),
|
||||
isUsed: bool,
|
||||
eventId: str,
|
||||
userId: str.describe('The payer'),
|
||||
registrationOptionId: str,
|
||||
attendeeId: optStr.describe('A named ticket: the person it\'s for'),
|
||||
holderUserId: optStr,
|
||||
createdAt: date,
|
||||
});
|
||||
|
||||
export const auditEntry = obj({
|
||||
id: str,
|
||||
action: str,
|
||||
actorRole: optStr,
|
||||
targetType: optStr,
|
||||
targetId: optStr,
|
||||
metadata: anyObj.nullish(),
|
||||
ip: optStr,
|
||||
createdAt: date,
|
||||
actor: userRef.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* What the common query parameters mean, for routes that declare them by name only (`filters(…)`
|
||||
* in the resource files). The OpenAPI document uses these when a parameter has no description.
|
||||
*/
|
||||
export const QUERY_HELP: Record<string, string> = {
|
||||
page: 'Page number, from 1',
|
||||
limit: 'How many per page',
|
||||
eventId: 'Only this event',
|
||||
eventIds: 'These events (comma-separated ids)',
|
||||
registrationId: 'Only this registration',
|
||||
userId: 'Only this person',
|
||||
actorId: 'Only things this person did',
|
||||
sessionId: 'Multi-day events: the session (default: today\'s)',
|
||||
deviceId: 'This device\'s id (offline scanning)',
|
||||
from: 'On or after (ISO date or date-time)',
|
||||
to: 'On or before (ISO date or date-time)',
|
||||
startDate: 'On or after (ISO date)',
|
||||
endDate: 'On or before (ISO date)',
|
||||
before: 'Older than (ISO date-time), for the next page',
|
||||
cursor: 'Where the last page ended (its `nextCursor`)',
|
||||
search: 'Text to search for',
|
||||
q: 'Text to search for',
|
||||
status: 'Only this status',
|
||||
role: 'admin, supervisor, staff or user',
|
||||
isActive: '`true` or `false`',
|
||||
campus: 'Campus id or slug (`all` for every campus)',
|
||||
series: 'Event series slug',
|
||||
format: 'File type, e.g. `pdf` or `xlsx`',
|
||||
log: '`1` also writes it to the access log (a list with personal details)',
|
||||
all: '`true`/`1`: include everything, not just the current ones',
|
||||
medical: '`true`: include medical notes (supervisors; logged)',
|
||||
type: 'Which kind',
|
||||
action: 'Only this action',
|
||||
sensitive: '`true`: only access to sensitive information',
|
||||
version: 'A version number (default: the current one)',
|
||||
provider: 'yoco or payfast',
|
||||
method: 'Payment method: cash, card, eft, other',
|
||||
kind: 'payment or refund',
|
||||
donations: '`1`: only donations and the parts put towards registrations',
|
||||
channel: 'email or whatsapp',
|
||||
email: 'An email address',
|
||||
phone: 'A phone number',
|
||||
phoneNumber: 'A phone number',
|
||||
past: '`true`: include ones that are over too',
|
||||
unpaid: '`true`: only ones with money still owed',
|
||||
unreviewed: '`true`: only the ones not reviewed yet',
|
||||
seriesId: 'Only this event series',
|
||||
registrants: '`true`: also people registered for its events in the last 12 months',
|
||||
};
|
||||
|
||||
/** What path parameters are, when the route doesn't say. */
|
||||
export const PATH_HELP: Record<string, string> = {
|
||||
id: 'The id',
|
||||
userId: 'The person\'s id',
|
||||
attendeeId: 'The named attendee\'s id',
|
||||
attachmentId: 'The attachment\'s id',
|
||||
familyId: 'The family\'s id',
|
||||
childId: 'The child\'s id',
|
||||
slug: 'The short name in the address',
|
||||
token: 'The token from the link (it is the credential)',
|
||||
qrCode: 'What the ticket\'s QR code holds',
|
||||
code: 'The code',
|
||||
name: 'The name',
|
||||
filename: 'The file name',
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user