Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735a76cc74 | ||
|
|
e2e881be85 | ||
|
|
4ca896130b | ||
|
|
d2d8cb1934 | ||
|
|
ad40618267 | ||
|
|
fc63454439 | ||
|
|
8f6f90a151 | ||
|
|
dacd49652b | ||
|
|
4c066e67d9 | ||
|
|
0ffbb13a1f | ||
|
|
e9d1b1fb24 | ||
|
|
98173c1380 | ||
|
|
6297613d0d | ||
|
|
5efb4cd6dd | ||
|
|
e40803cbbc | ||
|
|
fc393a90c1 | ||
|
|
c1211db8cf | ||
|
|
fb42d6ac19 | ||
|
|
2fc9e08f89 | ||
|
|
78bcd7cc7c | ||
|
|
37731a4696 | ||
|
|
4752516b15 | ||
|
|
5dea712b72 | ||
|
|
f1714f9286 | ||
|
|
c0d01ce993 | ||
|
|
0414854dec | ||
|
|
dd537a1a16 | ||
|
|
2240c8a703 | ||
|
|
a7c8148588 | ||
|
|
96d1e67b85 | ||
|
|
e5d615e398 | ||
|
|
0253a1d5fa | ||
|
|
740897635f | ||
|
|
7ae0b6d23b | ||
|
|
6ef19d3290 | ||
|
|
51355069da | ||
|
|
3a96f9c1dc | ||
|
|
a7261db191 | ||
|
|
6045d1b05e | ||
|
|
5df04f5a4a |
+371
@@ -7,6 +7,377 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 registrations and payments:** `POST /registrations` (signed in, guest for free
|
||||
events, or a waitlist link), `GET /registrations/{id}`, `POST /registrations/{id}/cancel` and
|
||||
`POST /registrations/{id}/checkout` (online payment, with an optional donation; answers
|
||||
`priceUpdated` with the new balance when an early-bird price ran out). Owners only (admins too);
|
||||
someone else's registration is `NOT_FOUND`.
|
||||
- **Services in TypeScript:** making and cancelling registrations moved from
|
||||
`registrationController.js` to `src/services/registrations.ts`, and paying a registration
|
||||
online from `paymentController.js` to `src/services/payments.ts`. v1 and v2 run the same code;
|
||||
`ServiceError` carries the HTTP status. `registrationController.js` is ~560 lines shorter.
|
||||
- **Typed frontend calls:** `npm run api:types` (repo root) writes the OpenAPI document to
|
||||
`frontend/src/lib/api-v2/openapi.json` and generates `schema.d.ts` from it (openapi-typescript);
|
||||
`apiV2(method, path, { params, query, body, authToken })` in `frontend/src/lib/apiV2.ts` checks
|
||||
the path, parameters, body and answer. A backend test fails while the saved document is stale.
|
||||
|
||||
### Changed
|
||||
|
||||
- The register page, "Pay now" (registration success page and My Events) and cancelling on My
|
||||
Events use API v2. `apiFetch` reads v2 errors (`error.message`, with `code`/`fields` in `data`).
|
||||
|
||||
## [1.51.0] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod
|
||||
schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what
|
||||
the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin)
|
||||
shown at **`/docs/v2`** (Swagger UI, served locally). One error shape
|
||||
`{ error: { code, message, fields? } }` with stable codes, and one list shape
|
||||
`{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`,
|
||||
`GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged.
|
||||
- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` →
|
||||
`dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed
|
||||
doors into JS modules in `src/v2/legacy.ts`.
|
||||
- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a
|
||||
launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is
|
||||
none), so existing PM2 configs keep working unchanged.
|
||||
- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then
|
||||
`npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to
|
||||
1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds.
|
||||
- e2e starts the backend with tsx.
|
||||
|
||||
## [1.50.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
|
||||
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
|
||||
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
|
||||
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
|
||||
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
|
||||
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
|
||||
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
|
||||
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
|
||||
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
|
||||
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
|
||||
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
|
||||
|
||||
### Changed
|
||||
|
||||
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
|
||||
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
|
||||
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
|
||||
events too.
|
||||
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
|
||||
|
||||
## [1.50.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
|
||||
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
|
||||
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
|
||||
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
|
||||
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
|
||||
used to stop silently at 200.
|
||||
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
|
||||
it used to show only the latest 50.
|
||||
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
|
||||
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
|
||||
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
|
||||
day). Both dates are now whole days in the site's time zone.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
|
||||
opens: it loads the latest payments, today's (for the totals), donations with what they were
|
||||
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
|
||||
registrations when they're picked. Faster as the years add up; the page works the same.
|
||||
|
||||
## [1.50.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
|
||||
payment, My Events and the self-service kiosk:
|
||||
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
|
||||
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
|
||||
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
|
||||
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
|
||||
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
|
||||
and its password eye can now be used with the keyboard.
|
||||
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
|
||||
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
|
||||
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
|
||||
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
|
||||
announced; option buttons say which one is chosen; error messages are announced when they
|
||||
appear.
|
||||
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
|
||||
so they don't rely on colour alone. The page honours "reduce motion" on the device.
|
||||
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
|
||||
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
|
||||
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
|
||||
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
|
||||
the kiosk switch). New dev dependency `@axe-core/playwright`.
|
||||
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
|
||||
|
||||
### Changed
|
||||
|
||||
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
|
||||
|
||||
## [1.49.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
|
||||
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
|
||||
when the page opens (but not over a link that already says what to show, such as the
|
||||
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
|
||||
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
|
||||
an account is anonymised.
|
||||
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
|
||||
`20261028180000_saved_filters`.
|
||||
|
||||
## [1.49.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
|
||||
all shown" after filtering, and act on them at once —
|
||||
- **Send a message** by email or WhatsApp, as an event update to the person who registered
|
||||
(placeholders as on Email attendees);
|
||||
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
|
||||
their tickets by email, as with a single payment;
|
||||
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
|
||||
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
|
||||
places on the new ticket are checked first;
|
||||
- **Put in a room or group** (one event) — their named people, with the group's capacity and
|
||||
gender rules;
|
||||
- **Export to Excel** (recorded in the access log, like other exports);
|
||||
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
|
||||
on the Payments page).
|
||||
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
|
||||
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
|
||||
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
|
||||
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
|
||||
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
|
||||
|
||||
## [1.49.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
|
||||
session today — with people checked in so far against places, children in now at kids church,
|
||||
and volunteers here against rostered (and how many short). Supervisors and admins also see money
|
||||
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
|
||||
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
|
||||
with — unpaid registrations for events in the next two weeks, young people waiting for parental
|
||||
consent, waitlists, cover requests, serving roles in the next week short of people, and for
|
||||
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
|
||||
webhook deliveries.
|
||||
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
|
||||
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
|
||||
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
|
||||
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
|
||||
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
|
||||
`/dashboard/admin/users?q=`.
|
||||
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
|
||||
|
||||
## [1.48.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
|
||||
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
|
||||
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
|
||||
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
|
||||
with families, children, rooms and check-ins (including some checked in right now), volunteer
|
||||
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
|
||||
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
|
||||
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
|
||||
people. See `backend/DEMO_DATA.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
|
||||
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
|
||||
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
|
||||
to them are cleared.
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
|
||||
who sees the roster and cover requests), following an event series, season passes, webhooks
|
||||
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
|
||||
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
|
||||
version show the new text straight away.
|
||||
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
|
||||
what changed in the built-in text since they last reviewed it, marks each section with new text
|
||||
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
|
||||
sections the notice doesn't have. **Done — hide this** records the review
|
||||
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
|
||||
"Significant change" if people should accept again) to make it live.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agreeing to the notice when an account was made for you** — activating an account (made by
|
||||
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
|
||||
Privacy Policy, as sign-up does (consent log source "Account activation").
|
||||
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
|
||||
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
|
||||
the built-in notice is in use. Before, they were only asked after a version marked as a
|
||||
significant change was published.
|
||||
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
|
||||
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
|
||||
|
||||
## [1.48.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
|
||||
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
|
||||
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
|
||||
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
|
||||
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
|
||||
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
|
||||
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
|
||||
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
|
||||
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
|
||||
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
|
||||
moment later, so a change that was rolled back sends nothing.
|
||||
- Safety and privacy: https addresses on public networks only (checked again at each send;
|
||||
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
|
||||
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
|
||||
days and with an anonymised account.
|
||||
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
|
||||
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
|
||||
|
||||
## [1.47.4] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Update requested, then a newer version came out** — the Licence page said the newer version
|
||||
was requested ("Version 1.47.3 is available. Update requested …") when the request was for an
|
||||
older one, and there was no way to ask for the newer version; the update banner stayed hidden.
|
||||
Now it shows the request ("You asked for version 1.47.0 on …") and, below it, that a newer
|
||||
version has come out with **Ask for 1.47.3 instead** (Withdraw request stays). The banner shows
|
||||
again and says to change the request. Changing it is audited as `hub_update_requested` with
|
||||
`changedFrom`.
|
||||
- With Cross Events Hub 1.5.0 the Hub moves the open request on to the new version and emails the
|
||||
provider once; with an older Hub the site withdraws the request and makes it again.
|
||||
|
||||
## [1.47.3] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Season passes** — one price for a ticket at each of several events in a series. On the Event
|
||||
series page supervisors make a pass (name, price, optional sale end) and tick which events and
|
||||
which ticket (and variant) at each it includes; the form shows what they'd cost separately. Youth
|
||||
(parental consent) events, staff-only tickets and per-session tickets can't be on a pass. Events
|
||||
and price are fixed once someone has bought it (switch it off instead); campus-scoped supervisors
|
||||
can only use their campuses' events.
|
||||
- **Buying** — on the series page signed-in people choose how many and buy: they're registered at
|
||||
every event on the pass at once and pay once (a free pass gives the tickets straight away). If
|
||||
online payment isn't possible the places stay booked and "Your passes" offers **Pay now**. A pass
|
||||
stops selling when its first event starts.
|
||||
- **Money per event** — the price is split over the events in proportion to their normal ticket
|
||||
prices (whole cents). Each registration's line is priced at its event's share, and the payment is
|
||||
recorded as one payment per event (all linked to the purchase), so each event's cashup and reports
|
||||
count only its part. Pass registrations aren't repriced by early-bird changes, take no coupon
|
||||
codes, and their tickets can't be edited.
|
||||
- `/api/season-passes` (list, create, change, delete; `:id/buy`; `purchases/:id/checkout`; `mine`),
|
||||
`passes` on `GET /api/series/by-slug/:slug`. Making, changing and buying passes is blocked in
|
||||
licence read-only mode. Purchases are in "Download my data". Audit action `season_pass_changed`
|
||||
(migration `20261028150000_season_pass`).
|
||||
|
||||
## [1.47.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
|
||||
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
|
||||
counted, donations applied to orders left out) and people checked in (a multi-session event shows
|
||||
the average per session), with the attendance rate and the change from the event before. Bar charts
|
||||
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
|
||||
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
|
||||
page opens it (`?report=seriesComparison&series=<id>`).
|
||||
- `GET /api/series/:id/compare`.
|
||||
|
||||
## [1.47.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
|
||||
one page. Supervisors make series on the new **Event series** page (name, web address,
|
||||
description, picture) and put an event in one with the **Series** box in the event editor; a
|
||||
copied event keeps its series. A series can have events at several campuses.
|
||||
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
|
||||
series shows "Part of …" and **Next in this series** on its page.
|
||||
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
|
||||
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
|
||||
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
|
||||
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
|
||||
series' followers.
|
||||
- Followed series are in "Download my data" and removed when an account is anonymised.
|
||||
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
|
||||
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
|
||||
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
|
||||
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
|
||||
`20261028140000_event_series`).
|
||||
|
||||
## [1.47.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Copy an event** — "Copy" on each card on the Events page opens the event editor filled in from
|
||||
that event: details, campus, sessions, ticket options, variants and early-bird tiers, sections,
|
||||
the registration form, notification recipients, the feedback questions, certificates and session
|
||||
reminders. Everything can be changed before saving. A **New start** box at the top moves every
|
||||
date by the same amount — the end, the sessions, the registration deadline, early-bird deadlines
|
||||
and the feedback date. On save the event's allocation groups (rooms, buses, teams) are copied
|
||||
too, and for admins its planned costs (a cost on a ticket option goes to the option with the
|
||||
same name). Not copied: registrations, coupons, attachments, bundles, the short link, the go-live
|
||||
time, cancelled sessions, kids check-in (kids ticket and rooms) and volunteer roles.
|
||||
- `POST /api/events/:id/copy-from` (blocked in licence read-only mode, campus-checked for both
|
||||
events); `POST /api/events` takes `clonedFromId`, recorded in the `event_created` audit entry.
|
||||
|
||||
## [1.46.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **What's new before you update** — Site settings → Licence shows the release notes for every
|
||||
version newer than the site, so you can decide whether to ask for the update. Updates that fix a
|
||||
security problem or a serious bug are marked, and show as a red banner.
|
||||
- **Withdraw request** — changed your mind after pressing Request update? Withdraw it on the Licence
|
||||
page and your provider is told not to update the site.
|
||||
- **Not now** — on the update banner or the Licence page: hides the "update available" reminder until
|
||||
the next version comes out ("Remind me again" turns it back on). Urgent updates can't be put off.
|
||||
- **Notices from your provider** — planned maintenance and other messages from your provider show as
|
||||
a banner at the top of the dashboard for site admins and supervisors, at the times given. Close
|
||||
one with ×; current notices are also listed on the Licence page.
|
||||
|
||||
- **`WHATS-NEW.md`** — the release notes churches see, in plain language. The Hub reads it from git
|
||||
with the version tags, so a release needs its section there before tagging.
|
||||
|
||||
Needs Cross Events Hub 1.4.0 for the notes, withdrawing and notices; with an older Hub the site
|
||||
works as before.
|
||||
|
||||
## [1.46.1] - 2026-10-28
|
||||
|
||||
### Removed
|
||||
|
||||
+2
-1
@@ -305,7 +305,8 @@ cd /opt/hope-events
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
|
||||
```
|
||||
|
||||
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
|
||||
It backs up the database, checks out the release, installs, migrates, builds (the backend too,
|
||||
since 1.51.0) and reloads PM2,
|
||||
and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
+134
-22
@@ -29,15 +29,17 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Multi-campus](#multi-campus)
|
||||
17. [Kids check-in](#kids-check-in)
|
||||
18. [Volunteer rosters](#volunteer-rosters)
|
||||
19. [Accounts & security](#accounts--security)
|
||||
20. [Privacy (POPIA)](#privacy-popia)
|
||||
21. [Help system](#help-system)
|
||||
22. [Background jobs](#background-jobs)
|
||||
23. [Operations](#operations)
|
||||
24. [Page map](#page-map)
|
||||
16. [Event series](#event-series)
|
||||
17. [Multi-campus](#multi-campus)
|
||||
18. [Kids check-in](#kids-check-in)
|
||||
19. [Volunteer rosters](#volunteer-rosters)
|
||||
20. [Accounts & security](#accounts--security)
|
||||
21. [Privacy (POPIA)](#privacy-popia)
|
||||
22. [Accessibility](#accessibility)
|
||||
23. [Help system](#help-system)
|
||||
24. [Background jobs](#background-jobs)
|
||||
25. [Operations](#operations)
|
||||
26. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +92,14 @@ Feedback → Attendance**.
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Copy an event** — "Copy" on an event card opens the editor filled in from that
|
||||
event (details, campus, sessions, options, variants, early birds, sections, form,
|
||||
notification recipients, feedback questions, certificates, reminders). Setting
|
||||
the **New start** moves every date with it (end, sessions, registration deadline,
|
||||
early-bird deadlines, feedback date). Allocation groups are copied on save, and
|
||||
for admins the planned costs (matched to options by name). Not copied:
|
||||
registrations, coupons, attachments, the short link, the go-live time, kids
|
||||
check-in and volunteer roles.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Multi-day events (sessions)** — one event on several dates, e.g. every
|
||||
@@ -405,9 +415,24 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
|
||||
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
|
||||
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
|
||||
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
|
||||
events** lists past ones (latest first), and typing searches every event by name, campus or date.
|
||||
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
|
||||
- **Dashboard** — **Today** (each event or session on today: checked in so far
|
||||
against places, children in now, volunteers here; money in today and this
|
||||
week) and **Waiting for you** (unpaid registrations for the next two weeks,
|
||||
parental consent outstanding, waitlists, cover requests, roster gaps; for
|
||||
admins also cashups due, payments to reconcile, privacy requests, failed
|
||||
webhooks — each links to where it's dealt with); then KPIs (active events,
|
||||
revenue, donations, registrations, tickets sold, each vs. the previous month),
|
||||
revenue trend chart, top events, and recent scans. Staff see Today too.
|
||||
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
|
||||
and reports for the role, actions ("New event", "Scan tickets"…), and search:
|
||||
events; payments by amount ("R450") for supervisors; people and registrations
|
||||
for admins. Follows campus scoping.
|
||||
- **Manage events** — the 9-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
@@ -458,12 +483,20 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
[Privacy](#privacy-popia)).
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
form answers and payments (with who recorded each one). **Bulk actions** on
|
||||
ticked registrations (or all shown): message (email/WhatsApp), mark paid
|
||||
(records the balance by method), move to another ticket (same tickets and QR
|
||||
codes, repriced), put in a room or group, export to Excel, cancel — each
|
||||
through the same rules as one at a time, with a result per registration.
|
||||
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
|
||||
current filters, apply one with a click, star one as the default for the page
|
||||
(not applied over a deep link). Per person, on the server.
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, settings changes, and coupon codes
|
||||
(created, edited, turned off, applied or removed by staff).
|
||||
(created, edited, turned off, applied or removed by staff), and every later feature's admin
|
||||
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
@@ -489,7 +522,7 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
@@ -500,6 +533,7 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
| Other | Series: year on year (a series' events side by side — registrations, tickets, income, checked in, attendance rate, change from the one before) |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
@@ -519,7 +553,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
@@ -531,6 +565,33 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Event series
|
||||
|
||||
Separate events grouped on one page — every year's camp, the monthly men's breakfast (each event
|
||||
keeps its own registrations; for one event on several dates see multi-day events).
|
||||
|
||||
- **Event series** page (supervisors): name, web address (`/series/<slug>`), description, picture;
|
||||
each series lists its events (campus-scoped supervisors see only their campuses' events in it)
|
||||
and its number of followers. Admins can delete a series (its events stay).
|
||||
- **Series box** in the event editor's Basic details. A copied event keeps its series. A series can
|
||||
have events at several campuses.
|
||||
- **Public series page**: upcoming events as cards, then past events. Event pages in a series show
|
||||
"Part of …" and **Next in this series** (the next public event in it).
|
||||
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
|
||||
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
|
||||
unsubscribe link). The `series` job runs every 15 minutes.
|
||||
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
|
||||
compares the series' events — registrations, tickets, income, checked in (average per session on
|
||||
multi-session events), attendance rate — each against the one before.
|
||||
- **Season passes**: on the Event series page, a pass gives a ticket (and variant) at each event
|
||||
ticked, at one price (optional sale end; stops when its first event starts; no youth events,
|
||||
staff-only or per-session tickets). Buyers choose how many on the series page and are registered at
|
||||
every event at once with one payment (or tickets at once when free; "Pay now" later if online
|
||||
payment isn't possible). The price is split over the events in proportion to their normal prices;
|
||||
each event gets its own registration line and payment for its share, so cashups stay per event.
|
||||
Pass registrations aren't repriced, take no codes and can't be edited. Events and price lock once
|
||||
sold.
|
||||
|
||||
## Multi-campus
|
||||
|
||||
For a church with several campuses in one install (Site settings → Campuses, off by
|
||||
@@ -692,8 +753,12 @@ and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
after their next login. People agree to the notice at sign-up and when they activate an account
|
||||
made for them; anyone who never has is asked once after login. When the built-in text changes
|
||||
in an update, a site with its own published version sees what changed in the editor, with each
|
||||
changed section marked (**Use the new text**, **Use all the new text**, **Add** missing sections,
|
||||
**Done — hide this**). The "Change my cookie choice" button shows above the cookie section. The
|
||||
contact page shows the Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
@@ -716,7 +781,7 @@ Information Officer.
|
||||
and the people affected were told, and drafts of both notifications.
|
||||
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
@@ -727,6 +792,18 @@ log.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
|
||||
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
|
||||
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
|
||||
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
|
||||
motion" respected. Branding warns when the primary colour is too light for white text and
|
||||
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
|
||||
(`e2e/accessibility.spec.ts`) keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -735,6 +812,27 @@ falling back to a general guide.
|
||||
|
||||
---
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
Site settings → **Webhooks** (admins): send what happens to other systems as it happens.
|
||||
|
||||
- **Endpoints**: name, https address, and the event types to send — registration created / paid /
|
||||
cancelled, payment received / refunded, ticket checked in, account created, event published.
|
||||
Switch off, edit, delete, **Send test**, **New secret**.
|
||||
- **Messages**: JSON `{ id, type, createdAt, data }` (registration with items and totals, payment,
|
||||
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
|
||||
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
|
||||
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
|
||||
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
|
||||
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
|
||||
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
|
||||
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
|
||||
is added to the operator register; deliveries are deleted after 30 days and with an account.
|
||||
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
|
||||
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
|
||||
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
|
||||
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
|
||||
@@ -754,6 +852,8 @@ can't start, the same jobs run on in-process timers.
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
|
||||
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
|
||||
| `webhooks` — send webhook deliveries that are due, retry failures, delete deliveries after 30 days | Every minute (`WEBHOOKS_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
|
||||
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
|
||||
@@ -774,8 +874,14 @@ can't start, the same jobs run on in-process timers.
|
||||
never switches it off: past the end date there's a grace period (banner for site admins only),
|
||||
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
|
||||
created or edited; no new registrations or payments; tickets still scan, data can be exported).
|
||||
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
|
||||
which tells the provider through the Hub (the site never updates itself).
|
||||
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
|
||||
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
|
||||
itself). A request can be **withdrawn**; when a newer version comes out after asking, the page shows
|
||||
the version asked for and **Ask for … instead** moves the request on to the newer one. **Not now** hides the update banner until the next version; urgent updates (security or
|
||||
serious bug fixes) can't be put off and show a red banner.
|
||||
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
|
||||
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
|
||||
Licence. The provider can email them to the church too.
|
||||
See `docs/hub-protocol.md`.
|
||||
- **`/health`** — status, version, database, background jobs and last backup.
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
@@ -783,7 +889,12 @@ can't start, the same jobs run on in-process timers.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
|
||||
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
|
||||
events, my account and registrations, registering, cancelling and paying online (1.51.1); the
|
||||
frontend's v2 calls are typed from the OpenAPI document.
|
||||
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
|
||||
deploys build `dist/`, which `src/index.js` runs.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
|
||||
frontend against a separate, freshly seeded database: public pages, free and paid
|
||||
@@ -803,6 +914,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
|
||||
| `/series/[slug]` | Public | A series' page: upcoming and past events, Follow |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
|
||||
+5
-1
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
|
||||
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
|
||||
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
|
||||
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
|
||||
@@ -117,6 +117,10 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
|
||||
| `WEBHOOKS_JOBS_ENABLED` (env) | `true` | The `webhooks` job (every minute): sends deliveries that are due (retries), picks up sends cut off by a restart, deletes deliveries older than 30 days. New events are sent straight away without it. |
|
||||
| `WEBHOOKS_ENABLED` (env) | `true` | `false` stops noticing events for outbound webhooks altogether (nothing new is queued). |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` (env) | `false` | `true` lets webhook endpoints use http and private or local addresses — for local testing only (the e2e tests set it). |
|
||||
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
|
||||
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
|
||||
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
# What's new in Cross Events
|
||||
|
||||
What each version changes for the church, in plain language. Churches see these notes on Site
|
||||
settings → Licence before they ask for an update, so they can decide whether they want it.
|
||||
|
||||
<!--
|
||||
How this file is used: the Cross Events Hub reads it from main every 15 minutes, and shows a section
|
||||
only once its version is tagged (so write it before tagging; fixes to old sections show straight away). Each version is a "## 1.2.3" heading; add "— urgent" (or "security") to the heading for a
|
||||
security or serious bug fix, which churches can't put off. Under it, lines starting with "- " show as
|
||||
a list and other lines as paragraphs; **bold**, `code` and links are shown as plain text.
|
||||
Write for church admins, not developers: no environment variables, endpoints or job names. The
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.1
|
||||
|
||||
- **More groundwork for 2.0.** Registering, paying online and cancelling now run on the new
|
||||
version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.0
|
||||
|
||||
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
|
||||
way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use
|
||||
Cross Events.
|
||||
|
||||
## 1.50.2
|
||||
|
||||
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
|
||||
the door, payments, messages, forms, attendance and more) there is now a search box. The list
|
||||
opens on upcoming events, past ones are one click away, and typing finds any event by name,
|
||||
campus or date. The events list in Reports can be searched too.
|
||||
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
|
||||
another system: what is sent and when, how to check it came from you, and an example message for
|
||||
every kind of event.
|
||||
|
||||
## 1.50.1
|
||||
|
||||
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
|
||||
IP address, filtered by role, and its action list now includes every kind of action. Data
|
||||
requests can be searched and come a page at a time, and the webhook delivery log can show only
|
||||
failed deliveries and load older ones.
|
||||
- **Fix:** the audit log's "To" date now includes that day.
|
||||
- **Faster Payments page** for churches with a few years of payments behind them.
|
||||
|
||||
## 1.50.0
|
||||
|
||||
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
|
||||
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
|
||||
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
|
||||
every form field, button and error message read out correctly. Faint grey text is darker and
|
||||
links in sentences are underlined, so they're easier to read.
|
||||
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
|
||||
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
|
||||
|
||||
## 1.49.2
|
||||
|
||||
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
|
||||
under a name and get back to them with one click. Star one and it's ready every time you open the
|
||||
page. They're yours and follow you to any device.
|
||||
|
||||
## 1.49.1
|
||||
|
||||
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
|
||||
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
|
||||
them in a room or group, export them to Excel, or cancel them.
|
||||
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
|
||||
ticket, so any difference shows as owing.
|
||||
- You see what happened to each one, and anything that couldn't be done says why.
|
||||
|
||||
## 1.49.0
|
||||
|
||||
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
|
||||
people are checked in, children in at kids church, and volunteers here — plus money in today and
|
||||
this week.
|
||||
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
|
||||
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
|
||||
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
|
||||
start "New event", or find an event, a payment by amount ("R450") or a person.
|
||||
|
||||
## 1.48.2
|
||||
|
||||
- No changes for churches in this version — it adds demo data for showing Cross Events to other
|
||||
churches.
|
||||
|
||||
## 1.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
webhooks. If you haven't published your own version, your site shows the new text straight away.
|
||||
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
|
||||
built-in text, section by section, so you can take the new wording with one click and publish.
|
||||
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
|
||||
activate it, and anyone who never has is asked once after they next log in.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
|
||||
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
|
||||
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
|
||||
- Every message is signed so the other system can trust it, failures are tried again
|
||||
automatically, and a delivery log shows what was sent.
|
||||
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
|
||||
|
||||
## 1.47.4
|
||||
|
||||
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
|
||||
asked for, tells you when a newer one is out, and lets you ask for that one instead with one
|
||||
button.
|
||||
|
||||
## 1.47.3
|
||||
|
||||
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
|
||||
year's breakfasts. People buy it on the series page, pay once, and get a ticket for each event.
|
||||
- The money is shared out over the events in line with their normal ticket prices, so each event's
|
||||
cashup shows its part.
|
||||
- Set passes up on the Event series page. A pass stops selling when its first event starts.
|
||||
|
||||
## 1.47.2
|
||||
|
||||
- **Compare a series year on year.** A new report puts the events in a series side by side —
|
||||
registrations, tickets, income and how many came — with how much each went up or down from the
|
||||
one before. Open it from Reports, or with **Compare** on the Event series page.
|
||||
|
||||
## 1.47.1
|
||||
|
||||
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
|
||||
one page that shows what's coming up and what's been. Each event in a series links to the next one.
|
||||
- **Follow a series.** People can follow a series and get an email when the next event in it is
|
||||
out. It counts as news, so only people who chose to get news and announcements receive it.
|
||||
- A copied event stays in its series, and a series can have events at different campuses.
|
||||
|
||||
## 1.47.0
|
||||
|
||||
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
|
||||
and the editor opens with everything filled in: tickets and prices, early birds, sessions, the
|
||||
form, feedback questions, certificates and groups.
|
||||
- **Pick the new start date** and every other date moves with it — sessions, deadlines and
|
||||
early-bird dates included. Check each step, change what's different this time, and save.
|
||||
- Registrations, coupons, attachments, kids check-in and volunteer roles aren't copied.
|
||||
|
||||
## 1.46.2
|
||||
|
||||
- **See what's new before you update.** When a new version is out, the Licence page lists what it
|
||||
brings, so you can decide whether you want it.
|
||||
- **Changed your mind?** Withdraw an update request, and your provider is told not to update the site.
|
||||
- **Not now:** hide the "update available" reminder until the next version comes out. Updates that
|
||||
fix a security problem or a serious bug are marked in red and can't be put off.
|
||||
- **Notices from your provider**, such as planned maintenance, now show as a banner for site admins
|
||||
and supervisors.
|
||||
|
||||
## 1.46.1
|
||||
|
||||
- Behind-the-scenes maintenance. Nothing changes for you.
|
||||
|
||||
## 1.46.0
|
||||
|
||||
- **Volunteer rosters:** set up serving roles (Welcome, Parking, Kids, Sound…) per event or session.
|
||||
Anyone with an account can sign up under My account → Serving, or supervisors can assign people.
|
||||
- Reminders by WhatsApp or email the day before a shift.
|
||||
- **"Can't make it" swaps:** a volunteer asks for cover, and the first to accept takes the place.
|
||||
- Volunteers check in by scanning the event's QR code; kids-room volunteers count as leaders on duty.
|
||||
- Roster sheet with phone numbers and check-in times, and hours served per person.
|
||||
- Fixed: the free kids check-in ticket was offered on the public event page.
|
||||
|
||||
## 1.45.1
|
||||
|
||||
- The built-in privacy notice and terms of use are brought up to date for kids check-in, sessions,
|
||||
certificates and campuses. If you published your own privacy notice, review it under Admin →
|
||||
Privacy.
|
||||
|
||||
## 1.45.0
|
||||
|
||||
- **Kids check-in:** a secure check-in and pickup for children's ministry, run from a staff desk, with
|
||||
no label printers needed.
|
||||
- Parents keep their children's details (allergies, medical and custody notes) and authorised adults
|
||||
under My account → Kids.
|
||||
- Each check-in gives the family a pickup code by WhatsApp or email; children can only be collected
|
||||
with the code.
|
||||
- Rooms with age ranges, capacity and ratios, live room screens, and an offline desk mode.
|
||||
|
||||
## 1.44.0
|
||||
|
||||
- **Staff for some campuses:** supervisors and staff can be limited to certain campuses (Admin →
|
||||
Users). They then see only those campuses' events, plus events for all campuses, everywhere in the
|
||||
dashboard, reports and the scanner.
|
||||
- The scanner says which campus a ticket is for.
|
||||
|
||||
## 1.43.0
|
||||
|
||||
- **Campuses** for churches with more than one campus (Site settings → Campuses, off by default).
|
||||
- Events belong to one campus or to all campuses; the public events page has a campus filter and
|
||||
each campus gets its own page.
|
||||
- Reports, dashboards, broadcasts and the daily summary can be filtered by campus.
|
||||
- People can choose their home campus on their profile.
|
||||
|
||||
## 1.42.1
|
||||
|
||||
- Behind-the-scenes improvements to licence checking. Nothing changes for you.
|
||||
|
||||
## 1.42.0
|
||||
|
||||
- **Request update:** when a new version is out, press Request update on Site settings → Licence and
|
||||
your provider updates the site for you.
|
||||
|
||||
## 1.41.3
|
||||
|
||||
- The platform is now called **Cross Events**. Nothing else changes, and everyone stays signed in.
|
||||
|
||||
## 1.41.2
|
||||
|
||||
- Fixed: the Background jobs page showed last-run times two hours ahead.
|
||||
|
||||
## 1.41.1
|
||||
|
||||
- A site without a licence is now read-only until a licence key is connected.
|
||||
- Fixed: a site whose licence was released kept showing it as active.
|
||||
|
||||
## 1.41.0
|
||||
|
||||
- **Licences:** the site connects to Cross Events with a licence key (Site settings → Licence) and
|
||||
checks in every 15 minutes with its version and health — never anyone's personal information.
|
||||
- After a licence's end date there's a grace period with a reminder for site admins.
|
||||
- Site admins see when a newer version is available.
|
||||
|
||||
## 1.40.0
|
||||
|
||||
- **POPIA toolkit, part 3:** a privacy readiness checklist with a score, and links to fix each item.
|
||||
- **Retention settings:** choose how long personal information is kept. Inactive accounts are warned
|
||||
and then anonymised after 5 years (on by default).
|
||||
- **Breach register:** record security incidents, with draft messages for the people affected and
|
||||
for the Information Regulator.
|
||||
|
||||
## 1.39.0
|
||||
|
||||
- **POPIA toolkit, part 2:** Admin → Privacy (POPIA).
|
||||
- Edit and publish your own privacy notice, with numbered versions, and ask everyone to accept a new
|
||||
version when it changes significantly.
|
||||
- A register of the outside services that handle personal information, a consent log, and an access
|
||||
log of who downloaded personal information.
|
||||
|
||||
## 1.38.0
|
||||
|
||||
- **POPIA toolkit, part 1:** a Privacy page for everyone (My account → Privacy): choose whether to get
|
||||
news by email or WhatsApp, download your data, ask for a correction or delete your account.
|
||||
- Bulk messages are either **event updates** (to everyone booked) or **news** (only to people who
|
||||
opted in, with an unsubscribe link). Replying STOP on WhatsApp stops news.
|
||||
- Admins handle correction and deletion requests under Admin → Data requests.
|
||||
|
||||
## 1.37.4
|
||||
|
||||
- Behind-the-scenes improvements to updating the site. Nothing changes for you.
|
||||
|
||||
## 1.37.3
|
||||
|
||||
- Behind-the-scenes fix. Nothing changes for you.
|
||||
|
||||
## 1.37.2
|
||||
|
||||
- **Payment status checks:** online payments whose confirmation never arrived from PayFast are found
|
||||
and recorded automatically, with a "Check status" button under Payments → Reconcile.
|
||||
- A "Test connection" button for the PayFast settings.
|
||||
|
||||
## 1.37.1
|
||||
|
||||
- "Pay online" is no longer offered when online payments are off or the amount is under R15; people
|
||||
are told to pay at the event instead.
|
||||
|
||||
## 1.37.0
|
||||
|
||||
- **PayFast:** a second online payment provider (card, instant EFT and more), set up in Site
|
||||
settings → Payments.
|
||||
- When more than one provider is on, people choose how to pay.
|
||||
|
||||
## 1.36.0
|
||||
|
||||
- **Site settings → Payments:** turn online payment providers on or off and choose their order.
|
||||
- Online payments must be at least R15; smaller balances are paid at the door.
|
||||
|
||||
## 1.35.1
|
||||
|
||||
- **Waitlist per session** on events where people pick sessions.
|
||||
- "All sessions" tickets let people choose which sessions they'll come to.
|
||||
- At the door, walk-ins can be booked straight into today's session.
|
||||
- Several fixes for per-session tickets and stock limits.
|
||||
|
||||
## 1.35.0
|
||||
|
||||
- **Pick sessions:** a multi-session event can let people choose which sessions to come to — day
|
||||
passes for a conference, or a weekly drop-in class — with a price per session.
|
||||
- Each session can have its own capacity, and tickets only scan on their own session.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
- **Background jobs page** (Site settings → Background jobs): see every scheduled task, when it last
|
||||
ran, and run it now.
|
||||
- Scheduled tasks (reminders, summaries, backups) are more reliable and run on South African time.
|
||||
- Fixed: at the door, multi-day tickets showed as used up from the second session.
|
||||
|
||||
## 1.33.0
|
||||
|
||||
- **Multi-day events:** an event can run over several sessions (e.g. every Wednesday for 5 weeks);
|
||||
people register and pay once.
|
||||
- Check-in per session, optional reminders before each session, and late joining at a lower price.
|
||||
- **Attendance register** with ticks per session and Excel/PDF export.
|
||||
- **Completion certificates** with four designs, issued automatically, each with a QR code anyone can
|
||||
check.
|
||||
|
||||
## 1.32.0
|
||||
|
||||
- Event cards show an "Early bird" ribbon while early-bird pricing is running.
|
||||
|
||||
## 1.31.1
|
||||
|
||||
- A field for Google Search Console verification (Site settings → Organisation).
|
||||
|
||||
## 1.31.0
|
||||
|
||||
- The registration funnel recognises visits from Facebook, Instagram, Google and other sites without
|
||||
tagged links, and shows shares and QR code scans separately.
|
||||
- Events can appear in Google's event listings with their date, venue and tickets.
|
||||
|
||||
## 1.30.1
|
||||
|
||||
- All tickets — emailed, WhatsApped and printed — share one clean design, with bigger QR codes.
|
||||
|
||||
## 1.30.0
|
||||
|
||||
- People can add an optional **donation** when they register, paid together with their tickets.
|
||||
- Fixed: event pages with a waitlist could crash.
|
||||
|
||||
## 1.29.1
|
||||
|
||||
- The Coupon codes page is easier to use, including on phones, and hides codes that are off, expired
|
||||
or used up.
|
||||
|
||||
## 1.29.0
|
||||
|
||||
- Bundle deals come in four types: an exact set, pick any mix, buy X get Y free, and group discounts.
|
||||
|
||||
## 1.28.3
|
||||
|
||||
- Fixed: the bundle deals form didn't fit on the screen.
|
||||
|
||||
## 1.28.2
|
||||
|
||||
- The Bundle deals page has a new layout that works on phones.
|
||||
|
||||
## 1.28.1
|
||||
|
||||
- Bundle deals work at the door, in manual registration and on the self-service kiosk too.
|
||||
|
||||
## 1.28.0
|
||||
|
||||
- **Bundle deals:** sell a set of tickets together for one price, e.g. 2 adults + 3 kids for R220.
|
||||
They apply automatically, with no code, and the saving shows on invoices and receipts.
|
||||
|
||||
## 1.27.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.27.0
|
||||
|
||||
- **Cashups** record where each payment method's money ended up: in the bank, cash on hand, or still
|
||||
pending. Card fees and bank charges can be written off with a reason.
|
||||
- Pending money can be marked received later without reopening the event.
|
||||
- Every dashboard page has the same "Back to dashboard" link.
|
||||
|
||||
## 1.26.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.26.0
|
||||
|
||||
- Behind-the-scenes upgrades and security updates.
|
||||
- Fixed: the refund reason didn't show at the door.
|
||||
|
||||
## 1.25.1
|
||||
|
||||
- On phones, the cookie notice and the help button no longer hide each other or the menu.
|
||||
|
||||
## 1.25.0
|
||||
|
||||
- **Yoco settings** in Site settings: enter and check the secret key, and set up payment
|
||||
notifications with one click.
|
||||
|
||||
## 1.24.0
|
||||
|
||||
- **Ticket transfers** now need the new holder to accept, by a link sent to them.
|
||||
- Updated terms of use and privacy policy, with a full cookie policy and a "Cookie settings" link.
|
||||
- Fixed: activating an account didn't sign you in.
|
||||
|
||||
## 1.23.0
|
||||
|
||||
- **View as user:** admins can see the site exactly as someone else does, to help with problems.
|
||||
It's read-only, limited to 30 minutes and recorded in the audit log.
|
||||
|
||||
## 1.22.0
|
||||
|
||||
- **Registration funnel report:** see where people drop off between viewing an event and paying, by
|
||||
traffic source and device. Only visitors who allow it are counted.
|
||||
|
||||
## 1.21.0
|
||||
|
||||
- **Post-event feedback:** send a short survey after an event, with one reminder, and see the results
|
||||
in a Feedback summary report.
|
||||
- New form question types: star ratings and multiple choice.
|
||||
|
||||
## 1.20.0
|
||||
|
||||
- **Sponsor a spot:** people can pay for a place for someone who can't afford it, with an optional
|
||||
goal and progress bar.
|
||||
|
||||
## 1.19.0
|
||||
|
||||
- **Rooms & groups** for camps: put attendees into rooms, cabins, small groups or buses by drag and
|
||||
drop or automatically, with printable lists.
|
||||
- Events can ask each person's gender.
|
||||
|
||||
## 1.18.0
|
||||
|
||||
- **Ticket transfer:** hand a ticket to someone else before the event; the old QR code stops working.
|
||||
|
||||
## 1.17.0
|
||||
|
||||
- **Offline ticket scanning:** download an event's tickets to a device and keep scanning without a
|
||||
connection; scans sync when it's back online.
|
||||
|
||||
## 1.16.0
|
||||
|
||||
- **Waitlists** for sold-out tickets: when a place frees up, the next person is offered it
|
||||
automatically.
|
||||
|
||||
## 1.15.0
|
||||
|
||||
- **Parental consent** for youth events: parents give consent online, with optional medical and
|
||||
emergency details, before their child can be checked in.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
- **Named attendees:** when booking for several people, each person can be named and get their own
|
||||
ticket.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
- **Coupon codes:** percentage, fixed-amount or free codes, with limits on events, dates and uses.
|
||||
- Staff prices for tickets.
|
||||
- A Discounts report.
|
||||
|
||||
## 1.12.0
|
||||
|
||||
- Two-factor login is now required for admins and supervisors.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- **Two-factor login** for everyone who wants it: a code by email or WhatsApp after the password, and
|
||||
"Trust this device for 30 days".
|
||||
|
||||
## 1.10.6
|
||||
|
||||
- Security updates.
|
||||
|
||||
## 1.10.5
|
||||
|
||||
- Events that have started but not finished stay listed on the home and events pages.
|
||||
|
||||
## 1.10.4
|
||||
|
||||
- Fixed: "Add to calendar" didn't work.
|
||||
|
||||
## 1.10.3
|
||||
|
||||
- More admin actions are written to the audit log.
|
||||
|
||||
## 1.10.2
|
||||
|
||||
- Behind-the-scenes fix to error monitoring. Nothing changes for you.
|
||||
|
||||
## 1.10.1
|
||||
|
||||
- Security fixes for file uploads, and security updates.
|
||||
|
||||
## 1.10.0
|
||||
|
||||
- **Add to calendar** on event pages and in confirmation emails.
|
||||
- Better link previews and search engine listings for events.
|
||||
- **Nightly database backups**, which you can download from Site settings → Backups.
|
||||
- **Audit log** of refunds, donations, manual registrations, cancellations, event changes and
|
||||
settings changes.
|
||||
|
||||
## 1.9.5
|
||||
|
||||
- The site copes better with automated traffic probing for pages that don't exist.
|
||||
|
||||
## 1.9.4
|
||||
|
||||
- Behind-the-scenes server setting. Nothing changes for you.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
- People registered at the door or by staff get their account activation link straight away.
|
||||
- Fixed: the self-service kiosk's "Choose a password" didn't work.
|
||||
|
||||
## 1.9.2
|
||||
|
||||
- Contact-only events no longer get a daily summary email.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
- Fixed: the browser tab and home page showed the default name instead of your church's name.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
- Events have a **location**, with a "Directions" link and a map on the event and contact pages.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- **Contact-only events** (e.g. baptism) show a "Contact us" button instead of registration.
|
||||
- **Invoices and receipts** as branded PDFs with registration and payment emails, with a "pay now"
|
||||
link and QR code.
|
||||
- Fixed: buying more tickets after early bird ended re-priced the tickets already bought.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- **Brand colours:** primary, secondary and accent colours across the whole site and emails, a
|
||||
favicon, and a live preview. Colours can be suggested from your logo.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- Supervisors can unassign a donation that was applied to a registration.
|
||||
- Faster payments and dashboards.
|
||||
|
||||
## 1.5.4
|
||||
|
||||
- The home page shows "My Dashboard" to people who are signed in.
|
||||
|
||||
## 1.5.3
|
||||
|
||||
- Fixed: help popups could be taller than a phone screen.
|
||||
|
||||
## 1.5.2
|
||||
|
||||
- Dashboard tiles and charts fit better on phones.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- Fixed: scheduled WhatsApp messages were sent as emails.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- **Site redesign:** a new look across the whole site, new dashboards with trends and top events, and
|
||||
a "Need help?" button with a guide on every page.
|
||||
- A separate Contact page, and recent sign-ins on Profile & Security.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
- Fixes to how refunds and donations are counted in reports and cashups.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
- Reports fit better on phones.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
- **Reports redesigned:** filters that apply to every report, charts, and styled PDF and Excel
|
||||
exports, by email or WhatsApp too.
|
||||
- Cashups show who recorded each payment, with per-person cash counts.
|
||||
- A payment link placeholder for messages to attendees.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
- The self-service kiosk is safer with returning visitors' details, and asks before changing them.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- The self-service kiosk can find a returning visitor's account.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
- **Check-in at the door** by number of people, without scanning each QR code, and a confirmation
|
||||
message to the ticket holder.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- "Make payment" goes straight to paying the full balance.
|
||||
- Staff can record a payment straight after a manual registration.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- A payment history page in My account.
|
||||
- Registration status shows as a coloured badge.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Closed and past events are tidied away in My Events.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- The first release.
|
||||
@@ -77,6 +77,11 @@ WAITLIST_JOB_ENABLED=true
|
||||
PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
|
||||
VOLUNTEER_JOBS_ENABLED=true
|
||||
SERIES_JOBS_ENABLED=true
|
||||
WEBHOOKS_JOBS_ENABLED=true
|
||||
WEBHOOKS_ENABLED=true
|
||||
# Local testing only: lets webhook endpoints be http:// or on a private network
|
||||
WEBHOOKS_ALLOW_PRIVATE=false
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
|
||||
|
||||
> **API v2 (1.51.0 on):** `/api/v2` is described by OpenAPI 3.1. The reference is at `/docs/v2`
|
||||
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
|
||||
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
|
||||
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
|
||||
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0. The frontend calls v2 through the typed
|
||||
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
|
||||
|
||||
All authenticated requests require:
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
@@ -136,6 +143,7 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
|
||||
| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias |
|
||||
| POST | `/api/events` | supervisor+ | Create event |
|
||||
| PUT | `/api/events/:id` | supervisor+ | Update event |
|
||||
| POST | `/api/events/:id/copy-from` | supervisor+ | Copying an event: copy `fromEventId`'s allocation sets/groups (not kids rooms) and, for admins, planned costs into this new event (409 once it has registrations or groups) |
|
||||
| DELETE | `/api/events/:id` | admin | Delete event |
|
||||
| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) |
|
||||
| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
|
||||
@@ -170,6 +178,10 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
|
||||
}
|
||||
```
|
||||
|
||||
A copy made with "Copy" on the Events page is an ordinary `POST /api/events` from the event form,
|
||||
with `clonedFromId` (the source event, noted in the `event_created` audit entry), followed by the
|
||||
options, feedback form, sections and recipients as for a new event, then `POST /api/events/:id/copy-from`.
|
||||
|
||||
---
|
||||
|
||||
## Event Options & Variants
|
||||
@@ -238,7 +250,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
|
||||
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
|
||||
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
@@ -565,7 +577,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
@@ -638,7 +650,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/api/payments` | supervisor+ | All payments |
|
||||
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
|
||||
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
|
||||
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
|
||||
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
|
||||
@@ -844,10 +856,13 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable, urgentUpdate, updateSnoozed, releases: [{ version, releasedAt, urgent, notes }], updateRequest, notices }` — `releases` is what's new in each version newer than this one (from the Hub) |
|
||||
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
|
||||
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
|
||||
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
|
||||
| POST | `/api/hub/update-request/cancel` | admin | Withdraw the update request; the Hub tells the provider. Returns the status. Audited as `hub_update_request_withdrawn` |
|
||||
| POST | `/api/hub/update-snooze` | admin | `{ snooze }` — "Not now": `updateSnoozed` is true (no banner) until a newer version than the one put off is out. `400` when there's no update or it's urgent (security/serious fix). `{ snooze: false }` turns reminders back on. Audited as `hub_update_snoozed` / `hub_update_unsnoozed` |
|
||||
| GET | `/api/hub/notices` | supervisor | The provider's notices showing now: `[{ id, level: info\|maintenance\|warning, title, message, startsAt, endsAt, updatedAt }]`, from the last heartbeat |
|
||||
|
||||
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
|
||||
|
||||
@@ -925,6 +940,99 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
|
||||
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
|
||||
role linked to the room); checked-in ones are counted in `leaders`.
|
||||
|
||||
## Saved filters
|
||||
|
||||
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
|
||||
else's id answers 404).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
|
||||
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
|
||||
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
|
||||
| DELETE | `/api/saved-filters/:id` | Delete |
|
||||
|
||||
## Bulk actions on registrations
|
||||
|
||||
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
|
||||
`{ action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }`. Every id is campus-checked.
|
||||
|
||||
| action | params | What happens |
|
||||
|--------|--------|--------------|
|
||||
| `markPaid` | `method`: cash/eft/card/other, `paidAt?` | A payment for what's still owed (as `POST /api/payments`); refused in licence read-only mode |
|
||||
| `cancel` | — | As cancelling one registration (coupons released, waitlist offered); no refund |
|
||||
| `moveOption` | `fromOptionId`, `toOptionId` (same event) | The lines, tickets and attendees move to the new ticket, repriced; status recalculated; places checked; refused in read-only mode |
|
||||
| `allocate` | `setId`, `groupId` | Each named person placed, as `PUT /api/allocations/sets/:id/assignments` |
|
||||
| `message` | `channel`: email/whatsapp, `subject` (email), `text` | Per event, as `POST /api/events/:id/email-attendees` / `whatsapp-attendees` with `filter.registrationIds`, kind "update" |
|
||||
|
||||
## Dashboard and command palette
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/dashboard/today` | staff+ | `{ events: [{ id, title, startsAt, endsAt, sessionLabel, places, checkedIn, kidsIn, volunteers }], money: { today, todayCount, week } \| null, pending: [{ key, label, count, href, tone }] }` — money and pending for supervisors/admins; campus-scoped |
|
||||
| GET | `/api/dashboard/search?q=` | staff+ | `{ results: [{ type: event\|payment\|person\|registration, id, title, subtitle, href }] }` — payments by amount for supervisors, people and registrations for admins |
|
||||
|
||||
## Event series
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/series` | supervisor+ | Every series with `followers`, `eventCount` and its events (campus-scoped: their campuses' events only) |
|
||||
| POST | `/api/series` | supervisor+ | Create: `{ name, slug?, description?, picture? }` (slug made from the name). 409 when the slug is taken |
|
||||
| PUT | `/api/series/:id` | supervisor+ | Change any of those fields |
|
||||
| DELETE | `/api/series/:id` | admin | Delete; events stay without a series, followers dropped |
|
||||
| GET | `/api/series/by-slug/:slug` | public | `{ id, name, slug, description, picture, upcoming, past, following }` — public events only |
|
||||
| POST / DELETE | `/api/series/:id/follow` | user | Follow / unfollow |
|
||||
| GET | `/api/series/:id/compare` | supervisor+ | Year on year: `{ series, events: [{ eventId, title, startDate, registrations, tickets, income, checkedIn, attendanceRate, sessions, change }] }`, oldest first; `change` is % against the event before. Scoped staff: their campuses' events only |
|
||||
|
||||
Events take `seriesId` (null = none) on create and update; `GET /api/events?series=<slug>` lists one
|
||||
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
|
||||
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
Admin only. Endpoints receive `POST` JSON `{ id: "evt_…", type, createdAt, data }` with headers
|
||||
`X-CrossEvents-Event`, `X-CrossEvents-Delivery` and `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex>`
|
||||
where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anything else is retried
|
||||
(1 min, 5 min, 30 min, 2 h, 12 h). The same `id` can arrive more than once — de-duplicate on it.
|
||||
|
||||
| Type | `data` |
|
||||
|------|--------|
|
||||
| `registration.created` / `.paid` / `.cancelled` | `registration { id, status, totalDue, amountPaid, items[], seasonPassPurchaseId }`, `event`, `person` |
|
||||
| `payment.received` / `payment.refunded` | `payment { id, amount, method, provider, isDonation, registrationId }`, `event`, `person` |
|
||||
| `ticket.checked_in` | `checkIn { id, scannedAt, people, manual, offline, session }`, `ticket`, `event`, `person` |
|
||||
| `user.created` | `person { id, name, email, phone, role, createdAt }` |
|
||||
| `event.published` | `event { id, title, description, startDate, endDate, location, price, goLiveAt, url, campus, series }` |
|
||||
| `webhook.test` | `{ message }` (Send test) |
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
|
||||
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
|
||||
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
|
||||
|
||||
## Season passes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/season-passes?seriesId=` | supervisor+ | Passes with events, normal prices, `share` per event, `sold`, `unpaid` |
|
||||
| POST | `/api/season-passes` | supervisor+ | `{ seriesId, name, description?, price, saleEndsAt?, isActive?, items: [{ eventId, eventOptionId, variantId? }] }` |
|
||||
| PUT | `/api/season-passes/:id` | supervisor+ | Change it; events and price only while unsold (409) |
|
||||
| DELETE | `/api/season-passes/:id` | supervisor+ | Only while unsold |
|
||||
| POST | `/api/season-passes/:id/buy` | user | `{ quantity }` → `{ purchaseId, total, paid, redirectUrl, message }`; 409 when not on sale or too few places |
|
||||
| POST | `/api/season-passes/purchases/:id/checkout` | user (buyer) | A new checkout for what is still owed |
|
||||
| GET | `/api/season-passes/mine` | user | My purchases with their registrations |
|
||||
|
||||
A purchase creates one `Registration` per event (`seasonPassPurchaseId`), its line priced at the
|
||||
event's share. The checkout's metadata is `{ type: 'season_pass', purchaseId }`; the webhook records
|
||||
one `Payment` per registration (`seasonPassPurchaseId`; only the first carries `externalId`).
|
||||
`PUT /api/registrations/:id/options` and `POST /api/registrations/:id/coupon` answer 409 for pass
|
||||
registrations.
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
|
||||
|
||||
+21
-8
@@ -1,10 +1,19 @@
|
||||
# Demo data
|
||||
|
||||
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
|
||||
for showing the platform to other churches. It stages six upcoming events with
|
||||
registrations, payments, tickets and people, and ten completed events from the past year
|
||||
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
|
||||
check-in and cashup screen has something to show.
|
||||
The demo seeds replace everything in the database with a realistic set of demo data, for
|
||||
showing the platform to other churches. Pick the one that looks like the church you're
|
||||
showing it to:
|
||||
|
||||
| Command (repo root) | For | What's in it |
|
||||
|---|---|---|
|
||||
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
|
||||
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
|
||||
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
|
||||
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
|
||||
|
||||
`npm run demo:list` prints the same list. Kids church always has a session **today** with
|
||||
children checked in right now, so the kids desk and room screens have something live to
|
||||
show on any day.
|
||||
|
||||
> **This deletes real data.** Run it only against a database you're happy to wipe, such
|
||||
> as a dedicated demo deployment. It is not something to run on a live church site.
|
||||
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run set-demo # shows the database it will wipe and asks you to type "demo"
|
||||
npm run set-demo -- --yes # no prompt (for scripts)
|
||||
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
|
||||
npm run demo:ministry -- --yes # no prompt (for scripts)
|
||||
# or in backend/:
|
||||
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
|
||||
```
|
||||
|
||||
The campuses setting is switched on for `multicampus` and off for the others (site settings
|
||||
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
|
||||
|
||||
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
|
||||
for one run, set it in the environment first. A value already in the environment wins
|
||||
over `.env`:
|
||||
|
||||
+13
-4
@@ -145,21 +145,29 @@ npm run prisma:generate
|
||||
|
||||
### Demo data
|
||||
|
||||
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
# Development (auto-reload; runs the TypeScript in src/ through tsx)
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
# Production: build once per update, then start (src/index.js runs the build in dist/)
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Type-check only
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for full API documentation (admin only).
|
||||
Since 1.51.0 the backend is partly TypeScript (`allowJs`: `.js` compiles as it is, `.ts` is checked
|
||||
strictly). `src/index.js` is a small launcher: with plain Node it runs `dist/` when `dist/build.json`
|
||||
matches this version, otherwise it compiles on the fly with tsx. The server itself is `src/app.js`.
|
||||
|
||||
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for the v1 API documentation and `/docs/v2` for the v2 (OpenAPI) reference — both admin only.
|
||||
|
||||
---
|
||||
|
||||
@@ -532,6 +540,7 @@ Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with
|
||||
### PM2 (recommended)
|
||||
```bash
|
||||
npm install -g pm2
|
||||
npm run build
|
||||
pm2 start src/index.js --name hope-events-api
|
||||
pm2 save && pm2 startup
|
||||
```
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
testMatch: ['**/tests/**/*.test.js', '**/tests/**/*.test.ts'],
|
||||
// TypeScript (src/**/*.ts and tests) runs through ts-jest; plain JS is left as it is.
|
||||
transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] },
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
};
|
||||
|
||||
Generated
+1073
-11
File diff suppressed because it is too large
Load Diff
+23
-6
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.46.1",
|
||||
"version": "1.51.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"dev": "tsx watch src/index.js",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
@@ -14,7 +14,10 @@
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
|
||||
"set-demo": "node scripts/set-demo.js"
|
||||
"set-demo": "node scripts/set-demo.js",
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"openapi": "tsx scripts/openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
@@ -37,11 +40,23 @@
|
||||
"pg-boss": "^10.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^11.1.1"
|
||||
"swagger-ui-dist": "^5.34.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/swagger-ui-dist": "^3.30.6",
|
||||
"jest": "^30.4.2",
|
||||
"prisma": "^7.10.1"
|
||||
"prisma": "^7.10.1",
|
||||
"supertest": "^7.3.1",
|
||||
"ts-jest": "^29.4.15"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1",
|
||||
@@ -54,6 +69,8 @@
|
||||
"prisma@7.10.1": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
"@parcel/watcher": false,
|
||||
"@scarf/scarf": false,
|
||||
"esbuild": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_request_withdrawn';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_snoozed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_unsnoozed';
|
||||
@@ -0,0 +1,59 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "seriesAnnouncedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "seriesId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventSeries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventSeries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeriesFollow" (
|
||||
"id" TEXT NOT NULL,
|
||||
"seriesId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SeriesFollow_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventSeries_slug_key" ON "EventSeries"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeriesFollow_userId_idx" ON "SeriesFollow"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SeriesFollow_seriesId_userId_key" ON "SeriesFollow"("seriesId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_seriesId_idx" ON "Event"("seriesId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'season_pass_changed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "seasonPassPurchaseId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "seasonPassPurchaseId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPass" (
|
||||
"id" TEXT NOT NULL,
|
||||
"seriesId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"saleEndsAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeasonPass_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPassItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "SeasonPassItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPassPurchase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"total" DOUBLE PRECISION NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"checkoutId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeasonPassPurchase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPass_seriesId_idx" ON "SeasonPass"("seriesId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_eventId_idx" ON "SeasonPassItem"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_eventOptionId_idx" ON "SeasonPassItem"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_variantId_idx" ON "SeasonPassItem"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SeasonPassItem_passId_eventId_key" ON "SeasonPassItem"("passId", "eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_passId_idx" ON "SeasonPassPurchase"("passId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_userId_idx" ON "SeasonPassPurchase"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_checkoutId_idx" ON "SeasonPassPurchase"("checkoutId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPass" ADD CONSTRAINT "SeasonPass_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'webhook_endpoint_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookEndpoint" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"events" TEXT[],
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"operatorId" TEXT,
|
||||
"lastSuccessAt" TIMESTAMP(3),
|
||||
"lastFailureAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"endpointId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastStatusCode" INTEGER,
|
||||
"lastError" TEXT,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_userId_idx" ON "WebhookDelivery"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedFilter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"page" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"params" JSONB NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -162,6 +162,9 @@ model User {
|
||||
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
|
||||
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -319,10 +322,109 @@ model Event {
|
||||
volunteerOptionId String?
|
||||
volunteerCheckInToken String? @unique
|
||||
|
||||
// Event series (1.47.1): separate events grouped on one series page (Camp 2025, Camp 2026).
|
||||
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
|
||||
seriesId String?
|
||||
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
||||
seasonPassItems SeasonPassItem[]
|
||||
seriesAnnouncedAt DateTime?
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
@@index([campusId])
|
||||
@@index([seriesId])
|
||||
}
|
||||
|
||||
/// Separate events grouped together, e.g. every year's camp or the monthly men's breakfast.
|
||||
/// Not tied to a campus: a series can have events at several campuses.
|
||||
model EventSeries {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
picture String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events Event[]
|
||||
followers SeriesFollow[]
|
||||
passes SeasonPass[]
|
||||
}
|
||||
|
||||
/// Someone who wants to hear when a new event in the series is out (sent as news).
|
||||
model SeriesFollow {
|
||||
id String @id @default(uuid())
|
||||
seriesId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([seriesId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// A season pass (1.47.3): one price for a ticket at each of several events in a series. Buying
|
||||
/// it registers the buyer for every event on it in one checkout; the price is split across the
|
||||
/// events in proportion to their normal ticket prices (for each event's cashup).
|
||||
model SeasonPass {
|
||||
id String @id @default(uuid())
|
||||
seriesId String
|
||||
name String
|
||||
description String?
|
||||
price Float
|
||||
isActive Boolean @default(true)
|
||||
// Stops selling then (it always stops when the first event on it starts).
|
||||
saleEndsAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
|
||||
items SeasonPassItem[]
|
||||
purchases SeasonPassPurchase[]
|
||||
|
||||
@@index([seriesId])
|
||||
}
|
||||
|
||||
/// One event on a pass: which ticket (and variant) the pass gives there.
|
||||
model SeasonPassItem {
|
||||
id String @id @default(uuid())
|
||||
passId String
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
order Int @default(0)
|
||||
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([passId, eventId])
|
||||
@@index([eventId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
/// Someone's purchase of a pass: `quantity` passes, one registration per event.
|
||||
model SeasonPassPurchase {
|
||||
id String @id @default(uuid())
|
||||
passId String
|
||||
userId String
|
||||
quantity Int
|
||||
total Float
|
||||
// pending | paid | cancelled
|
||||
status String @default("pending")
|
||||
// The online checkout started for it (our ProviderCheckout id).
|
||||
checkoutId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Restrict)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
|
||||
@@index([passId])
|
||||
@@index([userId])
|
||||
@@index([checkoutId])
|
||||
}
|
||||
|
||||
model EventOption {
|
||||
@@ -346,6 +448,7 @@ model EventOption {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -382,6 +485,7 @@ model OptionVariant {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -412,6 +516,10 @@ model Registration {
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
// Season pass (1.47.3): one registration per event on the pass, its line priced at that
|
||||
// event's share of the pass price (never repriced, no coupons, options not editable).
|
||||
seasonPassPurchaseId String?
|
||||
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
volunteerSlots VolunteerSlot[]
|
||||
@@ -502,6 +610,10 @@ model Payment {
|
||||
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
||||
splitPayments Payment[] @relation("SplitPayments")
|
||||
providerTransactions ProviderTransaction[]
|
||||
// Season pass: one provider payment is recorded as one Payment per event on the pass (its
|
||||
// share), all linked to the purchase. Only the first carries the provider's externalId.
|
||||
seasonPassPurchaseId String?
|
||||
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([recordedById])
|
||||
@@ -682,6 +794,9 @@ enum AdminAuditAction {
|
||||
breach_updated
|
||||
hub_connected
|
||||
hub_update_requested
|
||||
hub_update_request_withdrawn
|
||||
hub_update_snoozed
|
||||
hub_update_unsnoozed
|
||||
campuses_toggled
|
||||
campus_created
|
||||
campus_updated
|
||||
@@ -699,6 +814,12 @@ enum AdminAuditAction {
|
||||
volunteer_assigned
|
||||
volunteer_removed
|
||||
volunteer_checked_in_manual
|
||||
series_created
|
||||
series_updated
|
||||
series_deleted
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
registrations_bulk
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2131,3 +2252,66 @@ model VolunteerSwap {
|
||||
@@index([roleId, status])
|
||||
@@index([slotId])
|
||||
}
|
||||
|
||||
/// Outbound webhooks (1.48.0): a URL that is sent what happens here (registrations, payments,
|
||||
/// check-ins, new accounts, published events), signed with its own secret.
|
||||
model WebhookEndpoint {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
url String
|
||||
// HMAC secret, encrypted (utils/encryption.js); shown to the admin once, when made or rotated.
|
||||
secret String
|
||||
// Event types it gets, e.g. ["registration.paid", "ticket.checked_in"].
|
||||
events String[]
|
||||
isActive Boolean @default(true)
|
||||
// The operator register entry made for it (POPIA), if any.
|
||||
operatorId String?
|
||||
lastSuccessAt DateTime?
|
||||
lastFailureAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deliveries WebhookDelivery[]
|
||||
}
|
||||
|
||||
/// One message to one endpoint, with its retries. Kept 30 days.
|
||||
model WebhookDelivery {
|
||||
id String @id @default(uuid())
|
||||
endpointId String
|
||||
// The event's own id (the same for every endpoint it went to) and type.
|
||||
eventId String
|
||||
type String
|
||||
payload Json
|
||||
// pending | sending | retrying | succeeded | failed
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastStatusCode Int?
|
||||
lastError String?
|
||||
deliveredAt DateTime?
|
||||
// Whose personal information is in it, so it can be removed with their account.
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([endpointId, createdAt])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
|
||||
/// events, reports), `params` the filter values. At most one default per person and page.
|
||||
model SavedFilter {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
page String
|
||||
name String
|
||||
params Json
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Writes the /api/v2 OpenAPI document to frontend/src/lib/api-v2/openapi.json, from which the
|
||||
* frontend's types are generated (`npm run api:types` in the repo root does both). Run it after
|
||||
* adding or changing a v2 route; tests/v2.test.ts fails while the saved copy is out of date.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
import '../src/v2';
|
||||
|
||||
export const OPENAPI_FILE = path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'api-v2', 'openapi.json');
|
||||
|
||||
// The version is left out so a release bump doesn't change the file.
|
||||
export const currentDocument = () => JSON.stringify(buildOpenApi({ version: 'v2' }), null, 2) + '\n';
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(OPENAPI_FILE), { recursive: true });
|
||||
fs.writeFileSync(OPENAPI_FILE, currentDocument());
|
||||
console.log(`Wrote ${path.relative(process.cwd(), OPENAPI_FILE)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
+598
-19
@@ -2,9 +2,19 @@
|
||||
* set-demo.js
|
||||
*
|
||||
* Replaces the database contents with demo data for showing the system to other
|
||||
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
|
||||
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
|
||||
* completed events from the past year with door sales, check-ins, feedback and cashups.
|
||||
* churches. Four profiles, for different kinds of church (see PROFILES below, or
|
||||
* `npm run demo:list`):
|
||||
*
|
||||
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
|
||||
* events Events and conferences (the original demo, the default): six upcoming events
|
||||
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
|
||||
* analytics, and ten completed events with door sales, feedback and cashups.
|
||||
* ministry One campus with weekly ministry: a multi-session course (attendance and
|
||||
* certificates), a "pick your days" conference, kids church with kids check-in,
|
||||
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
|
||||
* multicampus Three campuses: events per campus and church-wide, home campuses,
|
||||
* campus-limited staff, a series across campuses, kids check-in and rosters at two
|
||||
* campuses.
|
||||
*
|
||||
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
|
||||
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
|
||||
@@ -13,10 +23,14 @@
|
||||
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
|
||||
* have no phone numbers, so later reminders can't reach a real person.
|
||||
*
|
||||
* Usage:
|
||||
* cd backend
|
||||
* npm run set-demo (asks for confirmation)
|
||||
* npm run set-demo -- --yes (no prompt)
|
||||
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
|
||||
* backend afterwards so it picks the setting up.
|
||||
*
|
||||
* Usage (from the repo root):
|
||||
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
|
||||
* npm run demo:list (what each one has)
|
||||
* or in backend/:
|
||||
* npm run set-demo -- --profile=ministry --yes (no prompt)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -38,6 +52,8 @@ const prisma = createPrismaClient();
|
||||
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
|
||||
const DEMO_PASSWORD = 'Demo@1234';
|
||||
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
|
||||
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
|
||||
const LIST_ONLY = process.argv.includes('--list');
|
||||
|
||||
// Not truncated: migrations, site settings, users (all but the admin are deleted
|
||||
// separately) and the admin's own account-security rows.
|
||||
@@ -161,13 +177,16 @@ const add = (model, row) => {
|
||||
};
|
||||
|
||||
const INSERT_ORDER = [
|
||||
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
|
||||
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
|
||||
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
|
||||
'ticketUsage', 'couponRedemption', 'registrationBundle',
|
||||
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
|
||||
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
|
||||
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
|
||||
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
|
||||
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
|
||||
];
|
||||
|
||||
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
|
||||
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
|
||||
quantity: l.quantity,
|
||||
priceSnapshot: price,
|
||||
appliedTierId: tier ? tier.id : null,
|
||||
// "Pick sessions" events: a per-session line.
|
||||
sessionId: l.sessionId || null,
|
||||
});
|
||||
add('registrationOptionTranche', {
|
||||
id: uuidv4(),
|
||||
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
|
||||
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
|
||||
*/
|
||||
function buildPastEvent(people, spec) {
|
||||
// Each profile has its own selection of past events (`always`: built by a profile's extras).
|
||||
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
|
||||
const { start } = spec;
|
||||
const event = addEvent({
|
||||
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
startDate: start,
|
||||
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Profiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
const UPCOMING = {
|
||||
mensBreakfast: buildMensBreakfast,
|
||||
familyFunDay: buildFamilyFunDay,
|
||||
womensConference: buildWomensConference,
|
||||
youthCamp: buildYouthCamp,
|
||||
carols: buildCarols,
|
||||
marriageWeekend: buildMarriageWeekend,
|
||||
};
|
||||
|
||||
/**
|
||||
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
|
||||
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
|
||||
* campuses: three campuses, switched on.
|
||||
*/
|
||||
const PROFILES = {
|
||||
small: {
|
||||
title: 'Small church — one congregation, the essentials',
|
||||
about: [
|
||||
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
|
||||
'Four past events with check-ins and cashups (one left open to cash up live).',
|
||||
'No campuses, kids check-in, volunteer rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
|
||||
extras: [],
|
||||
},
|
||||
events: {
|
||||
title: 'Events & conferences — the full events demo (default)',
|
||||
about: [
|
||||
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
|
||||
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
|
||||
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
|
||||
'One campus; no kids check-in, rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: Object.keys(UPCOMING),
|
||||
past: null,
|
||||
extras: [],
|
||||
},
|
||||
ministry: {
|
||||
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
|
||||
about: [
|
||||
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
|
||||
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
|
||||
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
|
||||
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
|
||||
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
|
||||
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
|
||||
},
|
||||
multicampus: {
|
||||
title: 'Multi-campus church — three campuses',
|
||||
about: [
|
||||
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
|
||||
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
|
||||
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
|
||||
'Kids church with kids check-in and volunteer rosters at Central and North.',
|
||||
'All six upcoming and ten past events of the events demo, spread over the campuses.',
|
||||
],
|
||||
campuses: true,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
|
||||
past: null,
|
||||
extras: ['series', 'kids', 'volunteers'],
|
||||
},
|
||||
};
|
||||
|
||||
let profile = PROFILES[PROFILE_NAME];
|
||||
|
||||
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
|
||||
let campuses = [];
|
||||
|
||||
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
|
||||
|
||||
function buildCampuses() {
|
||||
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
|
||||
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
|
||||
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
|
||||
});
|
||||
campuses = [
|
||||
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
|
||||
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
|
||||
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
|
||||
];
|
||||
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
|
||||
}
|
||||
|
||||
/** Spreads the events over the campuses; the big ones stay church-wide. */
|
||||
function assignCampuses() {
|
||||
const byTitle = {
|
||||
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
|
||||
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
|
||||
};
|
||||
let i = 0;
|
||||
for (const event of rows.event) {
|
||||
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
|
||||
if (event.seriesId || event.kidsCheckIn) continue;
|
||||
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
|
||||
event.campusId = idx === null ? null : campuses[idx].id;
|
||||
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
|
||||
}
|
||||
}
|
||||
|
||||
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
|
||||
async function scopeStaff(tx) {
|
||||
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
|
||||
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
|
||||
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event series and a season pass ──────────────────────────────────────────
|
||||
|
||||
function buildBreakfast(people, { start, title, sold }) {
|
||||
const event = addEvent({
|
||||
title,
|
||||
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
|
||||
startDate: start,
|
||||
endDate: addTime(start, 2.5 * HOUR),
|
||||
registrationDeadline: addTime(start, -2 * DAY),
|
||||
price: 150,
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'),
|
||||
location: 'Church hall',
|
||||
contactName: 'Sipho Dlamini',
|
||||
});
|
||||
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
|
||||
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
|
||||
for (const user of shuffle(people.men).slice(0, sold)) {
|
||||
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
|
||||
}
|
||||
return { event, ticket };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
|
||||
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
|
||||
* each breakfast is at a different campus.
|
||||
*/
|
||||
function buildSeries(people) {
|
||||
const series = add('eventSeries', {
|
||||
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
|
||||
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
|
||||
const pastSpec = (title, daysAgo, speaker) => ({
|
||||
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
|
||||
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
|
||||
options: [{ name: 'Breakfast ticket', price: 150 }],
|
||||
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
|
||||
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
|
||||
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
|
||||
showRate: 0.9,
|
||||
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
|
||||
});
|
||||
const past = [
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
|
||||
];
|
||||
const current = buildMensBreakfast(people);
|
||||
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
|
||||
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
|
||||
[...past, current, next.event, later.event].forEach((event, i) => {
|
||||
event.seriesId = series.id;
|
||||
// Followers were told when each came out (so the series job doesn't email demo people).
|
||||
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
|
||||
const campus = campusAt(i);
|
||||
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
|
||||
});
|
||||
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
|
||||
|
||||
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
|
||||
const pass = add('seasonPass', {
|
||||
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
|
||||
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
|
||||
});
|
||||
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
|
||||
for (const user of shuffle(people.men).slice(0, 6)) {
|
||||
const at = pastDate(15, 1);
|
||||
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
|
||||
[next, later].forEach((b, i) => {
|
||||
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
|
||||
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
|
||||
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
|
||||
add('payment', {
|
||||
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
|
||||
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
|
||||
});
|
||||
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
|
||||
});
|
||||
}
|
||||
return [current, next.event, later.event];
|
||||
}
|
||||
|
||||
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
|
||||
|
||||
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
|
||||
return Array.from({ length: count }, (_, k) => {
|
||||
const startsAt = addTime(first, k * everyDays * DAY);
|
||||
return add('eventSession', {
|
||||
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
|
||||
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check-ins per session that has ended, for the event's tickets. */
|
||||
function attendSessions(event, sessions, showRate) {
|
||||
const attended = new Map();
|
||||
for (const s of sessions.filter(x => x.endsAt < NOW)) {
|
||||
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
|
||||
if (!chance(showRate)) continue;
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
|
||||
attended.set(t.id, (attended.get(t.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return attended;
|
||||
}
|
||||
|
||||
const CERTIFICATE = (min) => ({
|
||||
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
|
||||
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
|
||||
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
|
||||
});
|
||||
|
||||
function buildCourses(people) {
|
||||
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
|
||||
const first = lastWeekday(15, 3, 18, 30);
|
||||
const alpha = addEvent({
|
||||
title: 'Alpha Course',
|
||||
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
|
||||
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
|
||||
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
|
||||
});
|
||||
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
|
||||
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
|
||||
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 30)) {
|
||||
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
|
||||
}
|
||||
attendSessions(alpha, alphaSessions, 0.85);
|
||||
|
||||
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
|
||||
const mFirst = lastWeekday(80, 4, 19);
|
||||
const marriage = addEvent({
|
||||
title: 'The Marriage Course',
|
||||
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
|
||||
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
|
||||
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
|
||||
});
|
||||
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
|
||||
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 14)) {
|
||||
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
|
||||
}
|
||||
const attended = attendSessions(marriage, mSessions, 0.88);
|
||||
const issuedAt = addTime(mSessions[5].endsAt, DAY);
|
||||
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
|
||||
const n = attended.get(t.id) || 0;
|
||||
if (n < 5) continue;
|
||||
const owner = rows.user.find(u => u.id === t.userId);
|
||||
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
|
||||
}
|
||||
return [alpha];
|
||||
}
|
||||
|
||||
function buildLeadershipConference(people) {
|
||||
const day1 = nextWeekday(40, 4, 8, 30);
|
||||
const event = addEvent({
|
||||
title: 'Lead Well Leadership Conference',
|
||||
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
|
||||
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
|
||||
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
|
||||
});
|
||||
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
|
||||
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
|
||||
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
|
||||
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
|
||||
const buyers = shuffle(people.everyone);
|
||||
for (const user of buyers.splice(0, 34)) {
|
||||
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
|
||||
}
|
||||
for (const user of buyers.splice(0, 26)) {
|
||||
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
|
||||
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
|
||||
}
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Kids church with kids check-in ──────────────────────────────────────────
|
||||
|
||||
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
|
||||
const inFamily = new Set();
|
||||
const kidsChurches = [];
|
||||
|
||||
function ageAt(dob, at) {
|
||||
let age = at.getFullYear() - dob.getFullYear();
|
||||
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
|
||||
return Math.max(0, age);
|
||||
}
|
||||
|
||||
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
|
||||
function kidsCode(taken) {
|
||||
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
|
||||
for (;;) {
|
||||
let c = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
|
||||
if (!taken.has(c)) { taken.add(c); return c; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
|
||||
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
|
||||
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
|
||||
*/
|
||||
function buildKidsChurch(people, campus = null) {
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const first = lastWeekday(28, 0, 9, 0);
|
||||
const event = addEvent({
|
||||
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
|
||||
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
|
||||
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
|
||||
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
|
||||
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
|
||||
});
|
||||
const sessions = addSessions(event, first, 12, { minutes: 90 });
|
||||
// A session today, so the desk and room screens have children in right now whatever day the
|
||||
// demo is shown (on a Sunday it's that Sunday's session).
|
||||
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
|
||||
if (!live) {
|
||||
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
|
||||
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
|
||||
sessions.push(live);
|
||||
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
|
||||
}
|
||||
const option = addOption(event, { name: 'Kids check-in', price: 0 });
|
||||
event.kidsOptionId = option.id;
|
||||
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
|
||||
event.kidsRoomSetId = set.id;
|
||||
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
|
||||
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
|
||||
|
||||
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
|
||||
const families = parents.map(parent => {
|
||||
inFamily.add(parent.id);
|
||||
const { last } = userMeta.get(parent.id);
|
||||
const createdAt = pastDate(200, 40);
|
||||
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
|
||||
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
|
||||
if (chance(0.5)) {
|
||||
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
|
||||
}
|
||||
const children = Array.from({ length: int(1, 3) }, () => {
|
||||
const girl = chance(0.5);
|
||||
const dob = new Date(NOW);
|
||||
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
|
||||
dob.setHours(0, 0, 0, 0);
|
||||
return add('child', {
|
||||
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
|
||||
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
|
||||
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
|
||||
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
|
||||
lastCheckInAt: null, createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
return { parent, family, children };
|
||||
});
|
||||
|
||||
const held = sessions.filter(s => s.endsAt < NOW);
|
||||
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
|
||||
let liveFamilies = 0;
|
||||
for (const f of families) {
|
||||
const attending = held.filter(() => chance(0.75));
|
||||
// About half the families are in right now (checked in, not yet collected).
|
||||
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
|
||||
if (inNow) { attending.push(live); liveFamilies++; }
|
||||
if (!attending.length) continue;
|
||||
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
|
||||
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
|
||||
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
|
||||
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
|
||||
const attendees = regLines[0].attendees;
|
||||
for (const s of attending) {
|
||||
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
|
||||
const isLive = s === live;
|
||||
f.children.forEach((c, i) => {
|
||||
if (!chance(0.92)) return;
|
||||
const at = isLive
|
||||
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
|
||||
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
|
||||
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
|
||||
const desk = pick(staffTeam);
|
||||
add('kidsCheckIn', {
|
||||
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
|
||||
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
|
||||
checkedInAt: at, checkedInById: desk.id,
|
||||
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
|
||||
checkedOutById: isLive ? null : pick(staffTeam).id,
|
||||
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
|
||||
});
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
|
||||
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const s of [...held, live]) {
|
||||
const isLive = s === live;
|
||||
if (isLive && live.startsAt > NOW) continue;
|
||||
for (const room of rooms) {
|
||||
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
|
||||
add('kidsRoomDuty', {
|
||||
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
|
||||
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
|
||||
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
kidsChurches.push({ event, sessions, rooms });
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
|
||||
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
|
||||
* marked as sent, so the reminder job doesn't email demo people.
|
||||
*/
|
||||
function addRoster(people, event, { session = null, roles, rooms = [] }) {
|
||||
const start = session ? session.startsAt : event.startDate;
|
||||
const held = (session ? session.endsAt : event.endDate) < NOW;
|
||||
const daysAway = (start - NOW) / DAY;
|
||||
const pool = shuffle(people.everyone);
|
||||
const slots = [];
|
||||
roles.forEach(([name, needed, roomName], order) => {
|
||||
const role = add('servingRole', {
|
||||
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
|
||||
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
|
||||
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
|
||||
for (const user of pool.splice(0, filled)) {
|
||||
const checked = held && chance(0.9);
|
||||
// Most scan the QR code themselves; now and then a staff member ticks them in.
|
||||
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
|
||||
slots.push(add('volunteerSlot', {
|
||||
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
|
||||
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
|
||||
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
|
||||
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
|
||||
}));
|
||||
}
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildVolunteers(people, built) {
|
||||
for (const { event, sessions, rooms } of kidsChurches) {
|
||||
event.volunteerCheckInToken = hex();
|
||||
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
|
||||
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
|
||||
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
|
||||
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
|
||||
coming.forEach((session, i) => {
|
||||
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
|
||||
// Someone can't make the next Sunday and has asked for cover.
|
||||
if (i === 0 && slots.length) {
|
||||
const slot = slots[0];
|
||||
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
|
||||
}
|
||||
});
|
||||
}
|
||||
const dayEvent = built.find(e => e.title === 'Family Fun Day');
|
||||
if (dayEvent) {
|
||||
dayEvent.volunteerCheckInToken = hex();
|
||||
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
|
||||
}
|
||||
}
|
||||
|
||||
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
|
||||
function buildExtras(people, built) {
|
||||
const out = [];
|
||||
const has = (x) => profile.extras.includes(x);
|
||||
if (has('series')) out.push(...buildSeries(people));
|
||||
if (has('courses')) out.push(...buildCourses(people));
|
||||
if (has('conference')) out.push(...buildLeadershipConference(people));
|
||||
if (has('kids')) {
|
||||
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
|
||||
else out.push(...buildKidsChurch(people));
|
||||
}
|
||||
if (has('volunteers')) buildVolunteers(people, built.concat(out));
|
||||
return out;
|
||||
}
|
||||
|
||||
function listProfiles() {
|
||||
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
|
||||
for (const [name, p] of Object.entries(PROFILES)) {
|
||||
console.log(` ${name.padEnd(12)} ${p.title}`);
|
||||
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Funnel analytics ────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
|
||||
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
|
||||
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
|
||||
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
|
||||
|
||||
for (const event of rows.event) {
|
||||
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
|
||||
for (const event of rows.event.filter(e => !e.isHidden)) {
|
||||
const raw = [];
|
||||
const session = (stages, at) => {
|
||||
const sessionId = uuidv4();
|
||||
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
|
||||
|
||||
async function clearData(tx) {
|
||||
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
|
||||
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
|
||||
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
|
||||
// after the admin's links to them are cleared.
|
||||
const userRefs = await tx.$queryRaw`
|
||||
SELECT DISTINCT ref.relname AS table, att.attname AS column
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_class ref ON ref.oid = c.confrelid
|
||||
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
|
||||
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
|
||||
const deleteInstead = new Set(userRefs.map(r => r.table));
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
|
||||
if (toClear.length) {
|
||||
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
|
||||
}
|
||||
if (userRefs.length) {
|
||||
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
|
||||
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
|
||||
}
|
||||
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
|
||||
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
|
||||
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
|
||||
@@ -1269,6 +1827,7 @@ function describeDatabase() {
|
||||
}
|
||||
|
||||
async function confirm(admin) {
|
||||
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
|
||||
if (SKIP_PROMPT) return true;
|
||||
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
|
||||
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
|
||||
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (LIST_ONLY) { listProfiles(); return; }
|
||||
if (!profile) {
|
||||
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
|
||||
listProfiles();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
|
||||
if (!admin) {
|
||||
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
|
||||
@@ -1295,15 +1861,11 @@ async function main() {
|
||||
|
||||
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
const people = buildPeople();
|
||||
const events = [
|
||||
buildMensBreakfast(people),
|
||||
buildFamilyFunDay(people),
|
||||
buildWomensConference(people),
|
||||
buildYouthCamp(people),
|
||||
buildCarols(people),
|
||||
buildMarriageWeekend(people),
|
||||
];
|
||||
if (profile.campuses) buildCampuses();
|
||||
const events = profile.upcoming.map(key => UPCOMING[key](people));
|
||||
events.push(...buildExtras(people, events));
|
||||
buildPastEvents(people);
|
||||
if (profile.campuses) assignCampuses();
|
||||
buildAnalytics();
|
||||
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
@@ -1311,6 +1873,13 @@ async function main() {
|
||||
for (const model of INSERT_ORDER) {
|
||||
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
|
||||
}
|
||||
if (profile.campuses) await scopeStaff(tx);
|
||||
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
|
||||
await tx.appSetting.upsert({
|
||||
where: { key: 'campuses_enabled' },
|
||||
update: { value: profile.campuses ? 'true' : 'false' },
|
||||
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
|
||||
});
|
||||
return result;
|
||||
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
|
||||
clearScheduledEmails();
|
||||
@@ -1348,7 +1917,17 @@ async function main() {
|
||||
].filter(Boolean).join(', ');
|
||||
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
|
||||
}
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
const extras = [
|
||||
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
|
||||
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
|
||||
count('eventSession') && `${count('eventSession')} sessions`,
|
||||
count('certificate') && `${count('certificate')} certificates`,
|
||||
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
|
||||
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
|
||||
].filter(Boolean);
|
||||
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
|
||||
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
|
||||
console.log(`\nKept: site settings and ${admin.email}.`);
|
||||
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
|
||||
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
|
||||
|
||||
+1812
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
function createBaseClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
@@ -11,6 +11,86 @@ function createPrismaClient() {
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound webhooks (utils/webhooks.js) notice what happens here, whichever code path does it:
|
||||
* registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, events
|
||||
* becoming public. Noticing only queues; the record is read again before anything is sent, so a
|
||||
* transaction that rolls back sends nothing. A failure here never affects the write itself.
|
||||
*/
|
||||
function withWebhooks(base) {
|
||||
const notice = (type, id) => {
|
||||
try { require('../utils/webhooks').notice(type, id); } catch (e) { console.warn('[webhooks] notice:', e?.message || e); }
|
||||
};
|
||||
const isPublic = e => !!e && e.isActive !== false && !e.isHidden;
|
||||
// The status before an update (read outside the caller's transaction: the committed value).
|
||||
const before = (model, where, select) => (where?.id ? base[model].findUnique({ where: { id: where.id }, select }).catch(() => null) : Promise.resolve(null));
|
||||
|
||||
return base.$extends({
|
||||
query: {
|
||||
registration: {
|
||||
async create({ args, query }) {
|
||||
const r = await query(args);
|
||||
if (r?.id) {
|
||||
notice('registration.created', r.id);
|
||||
if (r.status === 'paid') notice('registration.paid', r.id);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const status = args?.data?.status;
|
||||
if (status !== 'paid' && status !== 'cancelled') return query(args);
|
||||
const prev = await before('registration', args.where, { status: true });
|
||||
const r = await query(args);
|
||||
if (r?.id && prev && prev.status !== status) notice(status === 'paid' ? 'registration.paid' : 'registration.cancelled', r.id);
|
||||
return r;
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
async create({ args, query }) {
|
||||
const p = await query(args);
|
||||
// Applying an earlier donation to an order isn't new money (see cashupUtils.isDonationLeg).
|
||||
const leg = p && !p.isDonation && p.originalPaymentId && p.amount > 0;
|
||||
if (p?.id && !leg && p.amount !== 0) notice(p.amount < 0 ? 'payment.refunded' : 'payment.received', p.id);
|
||||
return p;
|
||||
},
|
||||
},
|
||||
ticketUsage: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('ticket.checked_in', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('user.created', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
event: {
|
||||
async create({ args, query }) {
|
||||
const e = await query(args);
|
||||
if (e?.id && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const touches = args?.data && ('isActive' in args.data || 'isHidden' in args.data);
|
||||
if (!touches) return query(args);
|
||||
const prev = await before('event', args.where, { isActive: true, isHidden: true });
|
||||
const e = await query(args);
|
||||
if (e?.id && prev && !isPublic(prev) && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPrismaClient() {
|
||||
return withWebhooks(createBaseClient());
|
||||
}
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
|
||||
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
const { page, limit, actorId, action, role, from, to, search } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -11,6 +11,9 @@ const sessionsUtil = require('../utils/sessions');
|
||||
const { publicOptions } = require('../utils/volunteers');
|
||||
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
const seriesUtil = require('../utils/series');
|
||||
|
||||
const SERIES_SELECT = { select: { id: true, name: true, slug: true } };
|
||||
|
||||
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
|
||||
async function assertCampusAllowed(req, campusId) {
|
||||
@@ -102,6 +105,20 @@ async function saveSchedule(tx, eventId, body) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
|
||||
// @route POST /api/events/:id/copy-from
|
||||
// @access Private/Supervisor
|
||||
const copyEventSetupFrom = async (req, res) => {
|
||||
try {
|
||||
const fromEventId = req.body?.fromEventId;
|
||||
if (!fromEventId) return res.status(400).json({ message: 'fromEventId is required' });
|
||||
const copied = await require('../utils/eventClone').copyEventSetup(String(fromEventId), req.params.id, { withCosts: req.user?.role === 'admin' });
|
||||
res.json(copied);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
if (!url) return url;
|
||||
@@ -144,6 +161,7 @@ const createEvent = async (req, res) => {
|
||||
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
|
||||
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
|
||||
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
|
||||
const seriesId = await seriesUtil.seriesForEvent(req.body.seriesId);
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -181,6 +199,7 @@ const createEvent = async (req, res) => {
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesId ? { seriesId } : {}),
|
||||
...scheduleData(req.body),
|
||||
};
|
||||
if (data.scheduleType === 'sessions') {
|
||||
@@ -196,7 +215,7 @@ const createEvent = async (req, res) => {
|
||||
action: 'event_created',
|
||||
targetType: 'Event',
|
||||
targetId: createdEvent.id,
|
||||
metadata: { title: createdEvent.title },
|
||||
metadata: { title: createdEvent.title, ...(req.body.clonedFromId ? { clonedFromId: String(req.body.clonedFromId) } : {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
@@ -349,6 +368,8 @@ const getEvents = async (req, res) => {
|
||||
// ?campus=<slug or id>: that campus's events plus church-wide ones.
|
||||
const enabled = await campusesEnabled();
|
||||
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
|
||||
// ?series=<slug>: one series' events.
|
||||
if (req.query.series) campusFilter.series = { slug: String(req.query.series) };
|
||||
try {
|
||||
events = await prisma.event.findMany({
|
||||
where: {
|
||||
@@ -358,7 +379,7 @@ const getEvents = async (req, res) => {
|
||||
endDate: { gte: new Date() },
|
||||
...campusFilter,
|
||||
},
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT }
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
|
||||
@@ -457,6 +478,7 @@ const getEventsAll = async (req, res) => {
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
@@ -484,7 +506,7 @@ const getEventById = async (req, res) => {
|
||||
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 } };
|
||||
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.
|
||||
@@ -639,6 +661,7 @@ const getEventById = async (req, res) => {
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
@@ -728,6 +751,9 @@ const updateEvent = async (req, res) => {
|
||||
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
|
||||
if (campusPick.error) throw new Error(campusPick.error);
|
||||
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
|
||||
const seriesPick = await seriesUtil.seriesForEvent(req.body.seriesId);
|
||||
// Moved into another series: that series' followers hear about it (once it's live).
|
||||
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
|
||||
if (campusChanged) {
|
||||
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
|
||||
}
|
||||
@@ -770,6 +796,7 @@ const updateEvent = async (req, res) => {
|
||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||
location: location !== undefined ? (location || null) : event.location,
|
||||
...(campusChanged ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||
...scheduleData(req.body, event),
|
||||
@@ -1575,6 +1602,9 @@ const emailEventAttendees = async (req, res) => {
|
||||
// Prefer server-side filtering when possible
|
||||
where.userId = { in: attendeeIds };
|
||||
}
|
||||
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
|
||||
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
|
||||
if (registrationIds.length > 0) where.id = { in: registrationIds };
|
||||
|
||||
// Name/email substring filter (kept for backward compatibility)
|
||||
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
||||
@@ -1843,6 +1873,9 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
if (attendeeIds.length > 0) {
|
||||
where.userId = { in: attendeeIds };
|
||||
}
|
||||
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
|
||||
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
|
||||
if (registrationIds.length > 0) where.id = { in: registrationIds };
|
||||
|
||||
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
||||
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
|
||||
@@ -2156,6 +2189,7 @@ const getEventByAlias = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
|
||||
@@ -13,15 +13,56 @@ function compareVersions(a, b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** An update request still waiting: until this site runs the version that was asked for. */
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
|
||||
/**
|
||||
* An update request still waiting: until this site runs the version that was asked for.
|
||||
* `outdated`: a newer version than the one asked for is out, so the request can be changed to it.
|
||||
*/
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion, latestVersion = null) {
|
||||
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
|
||||
return { requestedAt, version: requestedVersion };
|
||||
return { requestedAt, version: requestedVersion, outdated: !!latestVersion && compareVersions(latestVersion, requestedVersion) > 0 };
|
||||
}
|
||||
|
||||
const str = (v, max) => String(v ?? '').slice(0, max);
|
||||
|
||||
/** What's new in each version newer than this one (from the Hub), newest first. */
|
||||
function newerReleases(releases, version) {
|
||||
return (releases || [])
|
||||
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
|
||||
.sort((a, b) => compareVersions(b.version, a.version))
|
||||
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
|
||||
* serious bug fixes) can't be put off.
|
||||
*/
|
||||
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
|
||||
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
|
||||
}
|
||||
|
||||
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
|
||||
|
||||
/** The provider's notices showing now: started (or no start) and not ended. */
|
||||
function activeNotices(notices, now = new Date()) {
|
||||
return (notices || [])
|
||||
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
|
||||
.map(n => ({
|
||||
id: str(n.id, 64),
|
||||
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
|
||||
title: str(n.title, 120),
|
||||
message: str(n.message, 2000),
|
||||
startsAt: n.startsAt || null,
|
||||
endsAt: n.endsAt || null,
|
||||
updatedAt: n.updatedAt || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** What the admin pages show: licence state, Hub connection, version and update. */
|
||||
async function statusPayload({ fresh = false } = {}) {
|
||||
const s = await licenceStatus({ fresh });
|
||||
const releases = newerReleases(s.releases, APP_VERSION);
|
||||
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
|
||||
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
|
||||
return {
|
||||
state: s.state,
|
||||
readOnly: s.readOnly,
|
||||
@@ -45,8 +86,12 @@ async function statusPayload({ fresh = false } = {}) {
|
||||
lastError: s.lastError,
|
||||
version: APP_VERSION,
|
||||
latestVersion: s.latestVersion,
|
||||
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
|
||||
updateAvailable,
|
||||
urgentUpdate,
|
||||
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
|
||||
releases,
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
|
||||
notices: activeNotices(s.notices),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,8 +142,11 @@ const heartbeatNow = async (req, res) => {
|
||||
const requestUpdate = async (req, res) => {
|
||||
try {
|
||||
const result = await hub.requestUpdate();
|
||||
if (!result.alreadyRequested) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
|
||||
if (!result.alreadyRequested || result.changed) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub',
|
||||
metadata: { from: APP_VERSION, to: result.version, ...(result.changed ? { changedFrom: result.previousVersion || null } : {}) }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
@@ -107,4 +155,49 @@ const requestUpdate = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
|
||||
// @desc Withdraw the update request (the provider is told not to update after all)
|
||||
// @route POST /api/hub/update-request/cancel
|
||||
// @access Private/Admin
|
||||
const cancelUpdateRequest = async (req, res) => {
|
||||
try {
|
||||
await hub.cancelUpdateRequest();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
|
||||
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
|
||||
// @route POST /api/hub/update-snooze body: { snooze }
|
||||
// @access Private/Admin
|
||||
const snoozeUpdate = async (req, res) => {
|
||||
try {
|
||||
const snooze = req.body?.snooze !== false;
|
||||
const current = await statusPayload({ fresh: true });
|
||||
if (snooze) {
|
||||
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
|
||||
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
|
||||
}
|
||||
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The provider's notices showing now (banner for site admins and supervisors)
|
||||
// @route GET /api/hub/notices
|
||||
// @access Private/Supervisor
|
||||
const getNotices = async (req, res) => {
|
||||
try {
|
||||
const s = await licenceStatus();
|
||||
res.json(activeNotices(s.notices));
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const webhooks = require('../utils/webhooks');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
const audit = (req, endpoint, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'webhook_endpoint_changed', targetType: 'WebhookEndpoint', targetId: endpoint.id,
|
||||
metadata: { action, name: endpoint.name, host: hostOf(endpoint.url), ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
function hostOf(url) {
|
||||
try { return new URL(url).host; } catch { return null; }
|
||||
}
|
||||
|
||||
/** What goes to an endpoint, in words, for its operator register entry. */
|
||||
function dataShared(events) {
|
||||
const parts = [];
|
||||
if (events.some(e => e.startsWith('registration.') || e === 'ticket.checked_in' || e.startsWith('payment.') || e === 'user.created')) {
|
||||
parts.push('names, email addresses and phone numbers of people who register or have an account');
|
||||
}
|
||||
if (events.some(e => e.startsWith('registration.'))) parts.push('what they registered for and its status');
|
||||
if (events.some(e => e.startsWith('payment.'))) parts.push('payment and refund amounts');
|
||||
if (events.includes('ticket.checked_in')) parts.push('check-in times');
|
||||
if (events.includes('event.published')) parts.push('published event details');
|
||||
return parts.length ? `${parts.join('; ')}.` : 'Nothing yet (no event types chosen).';
|
||||
}
|
||||
|
||||
function cleanEvents(list) {
|
||||
const events = [...new Set((Array.isArray(list) ? list : []).map(String))].filter(t => webhooks.TYPES.has(t));
|
||||
if (!events.length) throw webhooks.webhookError('Choose at least one thing to send');
|
||||
return events;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim();
|
||||
if (!n) throw webhooks.webhookError('Give the webhook a name, e.g. the service it goes to');
|
||||
return n.slice(0, 100);
|
||||
}
|
||||
|
||||
const view = e => ({
|
||||
id: e.id, name: e.name, url: e.url, events: e.events, isActive: e.isActive, operatorId: e.operatorId,
|
||||
lastSuccessAt: e.lastSuccessAt, lastFailureAt: e.lastFailureAt, createdAt: e.createdAt,
|
||||
});
|
||||
|
||||
// @desc Endpoints, the event types, and the last week's delivery counts per endpoint
|
||||
// @route GET /api/outbound-webhooks
|
||||
// @access Private/Admin
|
||||
const list = async (req, res) => {
|
||||
try {
|
||||
const endpoints = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
const since = new Date(Date.now() - 7 * 864e5);
|
||||
const counts = await prisma.webhookDelivery.groupBy({ by: ['endpointId', 'status'], where: { createdAt: { gte: since } }, _count: { _all: true } });
|
||||
res.json({
|
||||
eventTypes: webhooks.EVENT_TYPES,
|
||||
keepDays: webhooks.KEEP_DAYS,
|
||||
endpoints: endpoints.map(e => ({
|
||||
...view(e),
|
||||
lastWeek: Object.fromEntries(counts.filter(c => c.endpointId === e.id).map(c => [c.status, c._count._all])),
|
||||
})),
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Add an endpoint: the secret is in this answer only. Also added to the operator register.
|
||||
// @route POST /api/outbound-webhooks { name, url, events }
|
||||
// @access Private/Admin
|
||||
const create = async (req, res) => {
|
||||
try {
|
||||
const name = cleanName(req.body?.name);
|
||||
const url = webhooks.cleanUrl(req.body?.url);
|
||||
const events = cleanEvents(req.body?.events);
|
||||
const secret = webhooks.newSecret();
|
||||
const u = new URL(url);
|
||||
// POPIA: whoever receives this is an operator of the church's personal information.
|
||||
const operator = await prisma.operator.create({
|
||||
data: { name: `${name} (webhook)`, purpose: 'Receives notifications from this site through a webhook', dataShared: dataShared(events), website: u.origin },
|
||||
}).catch(() => null);
|
||||
const endpoint = await prisma.webhookEndpoint.create({ data: { name, url, events, secret: webhooks.encrypt(secret), operatorId: operator?.id || null } });
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, endpoint, 'created', { events });
|
||||
res.status(201).json({ ...view(endpoint), secret });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route PUT /api/outbound-webhooks/:id { name?, url?, events?, isActive? }
|
||||
// @access Private/Admin
|
||||
const update = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const data = {};
|
||||
if (req.body?.name !== undefined) data.name = cleanName(req.body.name);
|
||||
if (req.body?.url !== undefined) data.url = webhooks.cleanUrl(req.body.url);
|
||||
if (req.body?.events !== undefined) data.events = cleanEvents(req.body.events);
|
||||
if (req.body?.isActive !== undefined) data.isActive = req.body.isActive !== false;
|
||||
const endpoint = await prisma.webhookEndpoint.update({ where: { id: current.id }, data });
|
||||
if (current.operatorId && (data.events || data.url || data.name || data.isActive !== undefined)) {
|
||||
await prisma.operator.update({
|
||||
where: { id: current.operatorId },
|
||||
data: { name: `${endpoint.name} (webhook)`, dataShared: dataShared(endpoint.events), website: new URL(endpoint.url).origin, active: endpoint.isActive },
|
||||
}).catch(() => {});
|
||||
}
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, endpoint, 'updated', { changedKeys: Object.keys(data) });
|
||||
res.json(view(endpoint));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete an endpoint and its delivery log; its operator entry is marked inactive
|
||||
// @route DELETE /api/outbound-webhooks/:id
|
||||
// @access Private/Admin
|
||||
const remove = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
await prisma.webhookEndpoint.delete({ where: { id: current.id } });
|
||||
if (current.operatorId) await prisma.operator.update({ where: { id: current.operatorId }, data: { active: false } }).catch(() => {});
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc A new secret (the old one stops working at once); in this answer only
|
||||
// @route POST /api/outbound-webhooks/:id/rotate-secret
|
||||
// @access Private/Admin
|
||||
const rotateSecret = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const secret = webhooks.newSecret();
|
||||
await prisma.webhookEndpoint.update({ where: { id: current.id }, data: { secret: webhooks.encrypt(secret) } });
|
||||
audit(req, current, 'secret_rotated');
|
||||
res.json({ secret });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Send a "webhook.test" now and say what came back
|
||||
// @route POST /api/outbound-webhooks/:id/test
|
||||
// @access Private/Admin
|
||||
const test = async (req, res) => {
|
||||
try {
|
||||
if (!(await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id }, select: { id: true } }))) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const d = await webhooks.sendTest(req.params.id);
|
||||
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc An endpoint's deliveries, newest first (?status=failed to filter)
|
||||
// @route GET /api/outbound-webhooks/:id/deliveries
|
||||
// @access Private/Admin
|
||||
const deliveries = async (req, res) => {
|
||||
try {
|
||||
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
|
||||
// "Load older": the next page is everything before the last one shown.
|
||||
const before = req.query.before ? new Date(String(req.query.before)) : null;
|
||||
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
|
||||
const rows = await prisma.webhookDelivery.findMany({
|
||||
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
|
||||
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Send a delivery again now
|
||||
// @route POST /api/outbound-webhooks/deliveries/:id/resend
|
||||
// @access Private/Admin
|
||||
const resend = async (req, res) => {
|
||||
try {
|
||||
const d = await webhooks.resend(req.params.id);
|
||||
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = { list, create, update, remove, rotateSecret, test, deliveries, resend, dataShared };
|
||||
@@ -6,7 +6,6 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const payments = require('../payments');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -354,7 +353,15 @@ const getPayments = async (req, res) => {
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
|
||||
// ?donations=1 (donations and the legs that apply them to registrations).
|
||||
const filters = [];
|
||||
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : null;
|
||||
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
|
||||
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
|
||||
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
@@ -933,54 +940,14 @@ const createCheckout = async (req, res) => {
|
||||
throw new Error('Either registrationId or eventId is required');
|
||||
}
|
||||
|
||||
// A registration's balance
|
||||
// A registration's balance (shared with POST /api/v2/registrations/:id/checkout)
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
||||
}
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
await assertEventOpen(reg.eventId, res);
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
||||
if (priceChanged) {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
const newTotal = computeRegistrationTotalDue(freshReg, new Date());
|
||||
const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0);
|
||||
return res.status(200).json({
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.'
|
||||
});
|
||||
}
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (amount && !isNaN(parseFloat(amount))) {
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
||||
const { startRegistrationCheckout } = require('../services/payments');
|
||||
const result = await startRegistrationCheckout(registrationId, req.user, {
|
||||
amount: amount && !isNaN(parseFloat(amount)) ? parseFloat(amount) : null,
|
||||
donationAmount: donationAmount === undefined || donationAmount === null || donationAmount === '' ? null : parseFloat(donationAmount),
|
||||
successUrl, cancelUrl, failureUrl, provider,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,12 +65,30 @@ const getNoticeDraft = async (req, res) => {
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
|
||||
builtInUpdates: await notice.builtInUpdates(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The built-in text changes have been gone through (the editor stops listing them)
|
||||
// @route POST /api/privacy/notice/builtin-reviewed
|
||||
// @access Private/Admin
|
||||
const markBuiltInReviewed = async (req, res) => {
|
||||
try {
|
||||
await notice.markBuiltInReviewed();
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
|
||||
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
|
||||
});
|
||||
res.json({ builtInUpdates: [] });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
@@ -425,6 +443,7 @@ const updateBreach = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
markBuiltInReviewed,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
|
||||
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
|
||||
const where = { selfService: false };
|
||||
if (status === 'open') where.status = 'open';
|
||||
else if (status === 'closed') where.status = { not: 'open' };
|
||||
const rows = await prisma.dataRequest.findMany({
|
||||
const q = String(req.query.search || '').trim().slice(0, 100);
|
||||
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
|
||||
// Closed requests pile up over the years, so they come a page at a time (newest first).
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
|
||||
where,
|
||||
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
|
||||
take: 200,
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
|
||||
});
|
||||
}), prisma.dataRequest.count({ where })]);
|
||||
const requests = await Promise.all(rows.map(async r => ({
|
||||
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
|
||||
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
|
||||
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
|
||||
overdue: r.status === 'open' && r.dueAt < new Date(),
|
||||
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
|
||||
})));
|
||||
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount });
|
||||
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
|
||||
@@ -3,534 +3,33 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
const { queueWaitlist } = require('../utils/waitlist');
|
||||
const { reservedOptionIds } = require('../utils/volunteers');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
function queueConsentRequests(registrationId) {
|
||||
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const TICKET_DELIVERY = ['payer', 'each'];
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
// Shared with POST /api/v2/registrations: the registration service (TypeScript, 1.51.1).
|
||||
const registrationService = require('../services/registrations');
|
||||
const {
|
||||
queueConsentRequests, ATTENDEES_INCLUDE, TICKET_DELIVERY, placesAfter, ACTIVE_COUPONS_INCLUDE,
|
||||
pendingCouponLines, priceRequestedOption, checkOptionStock, checkVariantStock, isStaffRole,
|
||||
} = registrationService;
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
function pendingCouponLines(event, resolvedOptions) {
|
||||
return buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch (e) {
|
||||
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
const { expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
// @desc Create a new registration
|
||||
// @route POST /api/registrations
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) }
|
||||
: true;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
if (!event.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('Event is not active');
|
||||
}
|
||||
|
||||
if (event.cashupStatus === 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('This event is closed and no longer accepting registrations.');
|
||||
}
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
||||
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
||||
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
const eventRequiresAuth = event.requiresAuth !== false;
|
||||
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
||||
res.status(401);
|
||||
throw new Error('Authentication required for this event');
|
||||
}
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!req.user && !waitlistOffer) {
|
||||
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
||||
// Also check against the requested options specifically
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) {
|
||||
res.status(400);
|
||||
throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId;
|
||||
if (req.user) {
|
||||
userId = req.user.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) {
|
||||
res.status(400);
|
||||
throw new Error('Name and email are required for unauthenticated registration');
|
||||
}
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) {
|
||||
res.status(409);
|
||||
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
}
|
||||
const existingGuest = match.user;
|
||||
if (existingGuest) {
|
||||
userId = existingGuest.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: guestName,
|
||||
email: guestEmail,
|
||||
password: '',
|
||||
phoneNumber: guestPhone || null,
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= registrationClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Registration is closed for this event');
|
||||
}
|
||||
|
||||
// Validate options
|
||||
if (!options || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= wholeCourseClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
const qty = option.quantity;
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e; // propagate stock errors
|
||||
// If stock check function fails (pre-migration), continue without stock check
|
||||
}
|
||||
|
||||
// Check variant stock (if a variant is selected)
|
||||
let variantId = option.variantId || null;
|
||||
let variantPrice = null;
|
||||
if (variantId && canIncludeVariants) {
|
||||
const variant = (eventOption.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e;
|
||||
}
|
||||
variantPrice = variant.price; // null = use option price
|
||||
}
|
||||
|
||||
// Resolve price: variant-level tiers take priority, then option-level tiers, then
|
||||
// base prices, then the staff price for Staff and above.
|
||||
let priceSnapshot = null;
|
||||
let appliedTierId = null;
|
||||
if (canIncludeTiers) {
|
||||
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
||||
} else {
|
||||
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
||||
}
|
||||
if (priceSnapshot === null) priceSnapshot = eventOption.price;
|
||||
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true }
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
||||
);
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400);
|
||||
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationOptionId: existing.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
|
||||
})
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => {
|
||||
return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0));
|
||||
}, 0);
|
||||
if (existingReg.status === 'paid' && newItemsCost > 0) {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } });
|
||||
} else {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } });
|
||||
}
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
// Create new registration
|
||||
const registrationId = uuidv4();
|
||||
registration = await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: option.quantity,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
||||
} catch (e) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If total due is zero, mark as paid and generate/update tickets if no required form
|
||||
let freeTicketMockReq = null, freeTicketMockRes = null;
|
||||
try {
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
if (totalDue === 0) {
|
||||
// Mark paid (no-op if already paid)
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
// check if event has a required form
|
||||
let hasRequiredForm = false;
|
||||
try {
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
||||
hasRequiredForm = !!(form && form.isRequired);
|
||||
} catch {}
|
||||
if (!hasRequiredForm) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registration.id);
|
||||
// Capture for chained email — tickets sent after registration confirmation
|
||||
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
|
||||
}
|
||||
|
||||
// reflect status change in the returned object
|
||||
registration.status = 'paid';
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
||||
const _regId = registration.id;
|
||||
const _freeTicketReq = freeTicketMockReq;
|
||||
const _freeTicketRes = freeTicketMockRes;
|
||||
(async () => {
|
||||
try {
|
||||
if (isNewRegistration) {
|
||||
await sendRegistrationEmails(_regId);
|
||||
} else {
|
||||
await sendRegistrationUpdatedEmails(_regId);
|
||||
}
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (_freeTicketReq) {
|
||||
try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics');
|
||||
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
||||
const actor = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
const { registration, couponError } = await registrationService.createRegistration(req.body || {}, actor);
|
||||
res.status(201).json({ ...registration, ...(couponError ? { couponError } : {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -569,7 +68,13 @@ const getRegistrations = async (req, res) => {
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
// Campus-scoped staff: registrations for events they can see.
|
||||
const where = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
const scopeWhere = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
// Narrowing filters, so callers needn't download every registration ever made:
|
||||
// ?unpaid=1 — still owing (not paid or cancelled); ?userId= — one person's.
|
||||
const filters = [];
|
||||
if (['1', 'true'].includes(String(req.query.unpaid || ''))) filters.push({ status: { in: ['pending', 'confirmed', 'partial_paid'] } });
|
||||
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
|
||||
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ where, include });
|
||||
return res.json(attachComputedTotalsToList(registrations));
|
||||
@@ -821,71 +326,7 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
// @access Private
|
||||
const cancelRegistration = async (req, res) => {
|
||||
try {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { endDate: true } } }
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
|
||||
// Check if user is authorized to cancel this registration
|
||||
if (registration.userId !== req.user.id && req.user.role !== 'admin') {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to cancel this registration');
|
||||
}
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit-block above)
|
||||
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400);
|
||||
throw new Error('This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
await assertEventOpen(registration.eventId, res);
|
||||
|
||||
// Check if registration can be cancelled
|
||||
if (registration.status === 'cancelled') {
|
||||
res.status(400);
|
||||
throw new Error('Registration is already cancelled');
|
||||
}
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (req.user.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({
|
||||
where: { registrationId: registration.id, amount: { gt: 0 } }
|
||||
});
|
||||
if (paymentCount > 0) {
|
||||
res.status(403);
|
||||
throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== req.user.id) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_cancelled',
|
||||
targetType: 'Registration',
|
||||
targetId: req.params.id,
|
||||
metadata: { registrationOwnerId: registration.userId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRegistration = await registrationService.cancelRegistration(req.params.id, req.user, getClientIp(req));
|
||||
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -2147,7 +1588,6 @@ async function syncStatusAfterDiscount(registrationId) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
|
||||
|
||||
// @desc Apply a coupon code to an existing registration
|
||||
// @route POST /api/registrations/:id/coupon
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { scopeFor, canManage } = require('../utils/campusScope');
|
||||
const passes = require('../utils/seasonPass');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
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); }
|
||||
};
|
||||
|
||||
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
|
||||
@@ -0,0 +1,126 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
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 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 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); }
|
||||
};
|
||||
|
||||
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
|
||||
@@ -1114,6 +1114,13 @@ const activateAccount = async (req, res) => {
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
|
||||
// Without it (an older page) they're asked once after login instead.
|
||||
if (req.body.acceptTerms === true) {
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
|
||||
}
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
|
||||
+59
-12
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
|
||||
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
|
||||
* Install → Hub only. Stored in AppSetting:
|
||||
* signed heartbeat every 15 minutes, and a signed update request (or withdrawal) when an admin
|
||||
* asks. Install → Hub only. Stored in AppSetting:
|
||||
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
|
||||
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
|
||||
* hub_update_requested_at, hub_update_requested_version.
|
||||
* hub_releases, hub_notices (JSON from the last heartbeat: what's new, provider's notices),
|
||||
* hub_update_requested_at, hub_update_requested_version,
|
||||
* hub_update_snoozed_version ("Not now" for that version; set here, not by the Hub).
|
||||
* These keys are kept out of the generic settings endpoints (settingsController).
|
||||
*/
|
||||
|
||||
@@ -16,7 +18,7 @@ const report = require('./report');
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
const TIMEOUT_MS = 15000;
|
||||
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
|
||||
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
|
||||
|
||||
function hubUrl() {
|
||||
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
|
||||
@@ -38,6 +40,14 @@ async function save(values) {
|
||||
clearLicenceCache();
|
||||
}
|
||||
|
||||
/** What's new and the provider's notices from a register/heartbeat reply (a Hub before 1.4.0 sends neither). */
|
||||
function fromHub(data) {
|
||||
return {
|
||||
hub_releases: Array.isArray(data.releases) ? JSON.stringify(data.releases.slice(0, 20)) : null,
|
||||
hub_notices: Array.isArray(data.notices) ? JSON.stringify(data.notices.slice(0, 20)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function read(keys) {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
@@ -101,6 +111,7 @@ async function connect(licenceKey, adminContact) {
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
...fromHub(data),
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
@@ -119,6 +130,7 @@ async function heartbeat() {
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
...fromHub(data),
|
||||
});
|
||||
return { ok: true, status: claims?.status || null };
|
||||
} catch (e) {
|
||||
@@ -133,22 +145,57 @@ async function heartbeat() {
|
||||
}
|
||||
}
|
||||
|
||||
/** a < b as x.y.z versions. */
|
||||
function olderThan(a, b) {
|
||||
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
|
||||
* Returns { requestedAt, version, alreadyRequested }.
|
||||
* Asking again after a newer version came out moves the request on to it (`changed`). A Hub before
|
||||
* 1.5.0 can't do that (it doesn't send `changed`), so the old request is withdrawn and made again.
|
||||
* Returns { requestedAt, version, alreadyRequested, changed, previousVersion }.
|
||||
*/
|
||||
async function requestUpdate() {
|
||||
let data;
|
||||
try {
|
||||
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
const ask = async () => {
|
||||
try {
|
||||
return await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
let data = await ask();
|
||||
if (data.alreadyRequested && data.changed === undefined) {
|
||||
const asked = (await read(['hub_update_requested_version'])).hub_update_requested_version || null;
|
||||
if (!asked || olderThan(asked, data.version)) {
|
||||
await cancelUpdateRequest();
|
||||
data = { ...(await ask()), changed: true, previousVersion: asked };
|
||||
}
|
||||
}
|
||||
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Withdraw the update request (§3a): the provider is told not to update after all. */
|
||||
async function cancelUpdateRequest() {
|
||||
try {
|
||||
await signedPost('/api/installs/update-request/cancel', { protocolVersion: PROTOCOL_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub can\'t withdraw update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
}
|
||||
await save({ hub_update_requested_at: null, hub_update_requested_version: null });
|
||||
}
|
||||
|
||||
/** "Not now": no update reminders until a newer version than `version` is out. Null turns them back on. */
|
||||
async function snoozeUpdate(version) {
|
||||
await save({ hub_update_snoozed_version: version || null });
|
||||
}
|
||||
|
||||
/**
|
||||
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
|
||||
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
|
||||
@@ -164,4 +211,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
|
||||
@@ -77,10 +77,19 @@ function checkDisabled() {
|
||||
|
||||
let cache = null;
|
||||
|
||||
function parseList(json) {
|
||||
try {
|
||||
const list = JSON.parse(json || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
|
||||
async function licenceStatus({ fresh = false } = {}) {
|
||||
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'] } } });
|
||||
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
const claims = verifyToken(s.hub_licence);
|
||||
const value = {
|
||||
@@ -95,6 +104,9 @@ async function licenceStatus({ fresh = false } = {}) {
|
||||
latestVersion: s.hub_latest_version || null,
|
||||
updateRequestedAt: s.hub_update_requested_at || null,
|
||||
updateRequestedVersion: s.hub_update_requested_version || null,
|
||||
updateSnoozedVersion: s.hub_update_snoozed_version || null,
|
||||
releases: parseList(s.hub_releases),
|
||||
notices: parseList(s.hub_notices),
|
||||
};
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
|
||||
@@ -13,6 +13,14 @@ const { licenceStatus } = require('./licence');
|
||||
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\/?$/],
|
||||
|
||||
+26
-1733
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,14 @@ const JOBS = [
|
||||
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
|
||||
run: () => require('../utils/volunteers').runReminderJob(),
|
||||
},
|
||||
{
|
||||
name: 'webhooks', label: 'Outbound webhooks: send what is due, retry failures, delete deliveries after 30 days', cron: '* * * * *', env: 'WEBHOOKS_JOBS_ENABLED',
|
||||
run: () => require('../utils/webhooks').runJob(),
|
||||
},
|
||||
{
|
||||
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
|
||||
run: () => require('../utils/series').runAnnounceJob(),
|
||||
},
|
||||
{
|
||||
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
|
||||
@@ -19,6 +19,12 @@ async function checkoutPaid(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
|
||||
if (tx) return true;
|
||||
// A season pass checkout: paid once its purchase is.
|
||||
const purchaseId = checkout.metadata?.purchaseId;
|
||||
if (purchaseId) {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: String(purchaseId) }, select: { status: true } });
|
||||
return purchase?.status === 'paid';
|
||||
}
|
||||
const registrationId = checkout.metadata?.registrationId;
|
||||
if (!registrationId) return false;
|
||||
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
|
||||
|
||||
@@ -71,7 +71,9 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
if (!existingRegistration) {
|
||||
const meta = tx.metadata || {};
|
||||
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
|
||||
if (!isDonation) {
|
||||
// A season pass checkout belongs to its purchase, not to one registration.
|
||||
const isSeasonPass = meta.type === 'season_pass' && !!meta.purchaseId;
|
||||
if (!isDonation && !isSeasonPass) {
|
||||
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -141,6 +143,11 @@ async function recordSucceededPayment(providerId, event, body) {
|
||||
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
|
||||
}
|
||||
|
||||
// Season pass: split over the purchase's registrations, one Payment per event.
|
||||
if (metadata.type === 'season_pass' && metadata.purchaseId) {
|
||||
return require('../utils/seasonPass').recordPassPayment(providerId, event, body);
|
||||
}
|
||||
|
||||
let registration = null;
|
||||
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
|
||||
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
const dashboard = require('../utils/dashboard');
|
||||
|
||||
// Both follow the person's campus scope inside utils/dashboard.js.
|
||||
|
||||
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
|
||||
// @route GET /api/dashboard/today
|
||||
// @access Private/Staff (money and waiting items for supervisors and admins)
|
||||
router.get('/today', protect, staff, scopeList, async (req, res) => {
|
||||
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
|
||||
// @route GET /api/dashboard/search
|
||||
// @access Private/Staff
|
||||
router.get('/search', protect, staff, scopeList, async (req, res) => {
|
||||
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
@@ -29,7 +30,7 @@ const {
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
@@ -55,6 +56,9 @@ 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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
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.
|
||||
@@ -9,5 +10,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
markBuiltInReviewed,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
@@ -64,6 +65,7 @@ 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);
|
||||
|
||||
@@ -17,7 +17,8 @@ const {
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
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).
|
||||
@@ -25,10 +26,10 @@ const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
// 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 }), updateRegistrationOptions);
|
||||
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 }), applyCoupon);
|
||||
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);
|
||||
@@ -45,5 +46,9 @@ 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;
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const filters = require('../utils/savedFilters');
|
||||
|
||||
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
|
||||
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
|
||||
|
||||
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
|
||||
router.get('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.post('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.put('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.delete('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* An error from a service (src/services/*) with the HTTP status it means. v2 turns it into its
|
||||
* error shape by the status (v2/errors.ts); v1 controllers keep answering as they always did.
|
||||
*/
|
||||
export class ServiceError extends Error {
|
||||
readonly statusCode: number;
|
||||
/** Extra, e.g. couponError or a code the caller can switch on. */
|
||||
readonly code?: string;
|
||||
|
||||
constructor(statusCode: number, message: string, code?: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export const fail = (statusCode: number, message: string, code?: string): never => {
|
||||
throw new ServiceError(statusCode, message, code);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Paying a registration online (1.51.1). Moved from paymentController.createCheckout so
|
||||
* POST /api/payments/checkout (v1) and POST /api/v2/registrations/:id/checkout share it.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const payments = require('../payments') as { MIN_ONLINE_AMOUNT: number };
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts') as {
|
||||
createRegistrationCheckout(registrationId: string, userId: string, opts: { successUrl?: string; cancelUrl?: string; failureUrl?: string; donationAmount?: number; amount?: number | null; provider?: string | null }): Promise<{
|
||||
checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string };
|
||||
}>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
refreshPricingForRegistration(id: string): Promise<{ changed: boolean }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
|
||||
export type CheckoutOptions = {
|
||||
/** Pay only this much of the balance (staff taking part payments). */
|
||||
amount?: number | null;
|
||||
/** An optional donation added on top (the register form's "Donation amount"). */
|
||||
donationAmount?: number | null;
|
||||
provider?: string | null;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
failureUrl?: string;
|
||||
};
|
||||
|
||||
export type CheckoutResult =
|
||||
| { priceUpdated: true; newTotal: number; message: string }
|
||||
| { priceUpdated?: false; checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string } };
|
||||
|
||||
/**
|
||||
* Starts an online payment for what's owed on a registration (the owner, or a supervisor or
|
||||
* admin). If an early-bird price ran out since it was made, nothing is charged: the caller is
|
||||
* told the new total to show first.
|
||||
*/
|
||||
export async function startRegistrationCheckout(registrationId: string, actor: { id: string; role: Role }, opts: CheckoutOptions = {}): Promise<CheckoutResult> {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const staffSide = actor.role === 'admin' || actor.role === 'supervisor';
|
||||
if (reg.userId !== actor.id && !staffSide) fail(403, 'Not authorized to create checkout for this registration');
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (!staffSide && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed } = await pricing.refreshPricingForRegistration(registrationId);
|
||||
if (changed) {
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const newTotal = pricing.computeRegistrationTotalDue(fresh, new Date());
|
||||
const totalPaid = fresh.payments.reduce((s, p) => s + p.amount, 0);
|
||||
return {
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.',
|
||||
};
|
||||
}
|
||||
|
||||
const urls = { successUrl: opts.successUrl, cancelUrl: opts.cancelUrl, failureUrl: opts.failureUrl, provider: opts.provider ?? null };
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (opts.amount !== undefined && opts.amount !== null && !isNaN(Number(opts.amount))) {
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, amount: Number(opts.amount) });
|
||||
}
|
||||
|
||||
let donation = 0;
|
||||
if (opts.donationAmount !== undefined && opts.donationAmount !== null) {
|
||||
donation = Math.round(Number(opts.donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) fail(400, 'Invalid donation amount');
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) fail(400, `Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
||||
}
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, donationAmount: donation });
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Making and cancelling registrations (1.51.1). Moved here from registrationController.js so
|
||||
* POST /api/registrations (v1) and POST /api/v2/registrations run the same code. Errors are
|
||||
* ServiceErrors carrying their HTTP status.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
attachComputedTotals<T>(registration: T): T & { totalDueComputed: number };
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
buildCouponLines(registration: any): any[];
|
||||
previewCoupon(args: { code: string; eventId: string; user: any; lines: any[] }): Promise<unknown>;
|
||||
applyCouponToRegistration(args: { registrationId: string; code: string }): Promise<unknown>;
|
||||
recalculateRegistrationDiscount(registrationId: string): Promise<number>;
|
||||
releaseRegistrationCoupons(registrationId: string): Promise<unknown>;
|
||||
};
|
||||
const attendeesUtil = require('../utils/attendees') as {
|
||||
lineKey(eventOptionId: string, variantId?: string | null): string;
|
||||
validateAttendees(event: any, places: Record<string, number>, attendees: unknown, existing: Record<string, number>): any[];
|
||||
createAttendees(registrationId: string, attendees: any[]): Promise<unknown>;
|
||||
namedPlacesByKey(registrationId: string): Promise<Record<string, number>>;
|
||||
};
|
||||
const { findAccountByContact } = require('../utils/accountMatch') as { findAccountByContact(c: { email?: string; phone?: string }): Promise<{ user: { id: string } | null; conflict?: boolean }> };
|
||||
const { ensureConsentRequests } = require('../utils/consent') as { ensureConsentRequests(registrationId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object, opts?: { excludeEntryId?: string | null }): Promise<number> };
|
||||
const waitlist = require('../utils/waitlist') as { findOfferByToken(token: string): Promise<any>; queueWaitlist(eventId: string): void };
|
||||
const { reservedOptionIds } = require('../utils/volunteers') as { reservedOptionIds(event: any): string[] };
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles') as { BUNDLE_APPS_INCLUDE: any };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
registrationClosesAt(event: any, sessions: any[]): Date;
|
||||
wholeCourseClosesAt(event: any, sessions: any[]): Date;
|
||||
expandPickLines(event: any, options: any[], opts: { now: Date }): any[];
|
||||
assertSessionCapacity(event: any, lines: any[], registrationId?: string, opts?: { excludeEntryId?: string }): Promise<void>;
|
||||
mergeSessionIds(a: string[] | null | undefined, b: string[] | null | undefined): string[];
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string, res?: unknown): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Role = 'admin' | 'supervisor' | 'staff' | 'user';
|
||||
export type Actor = { id: string; role: Role; email?: string } | null;
|
||||
|
||||
export const TICKET_DELIVERY = ['payer', 'each'] as const;
|
||||
export const isStaffRole = (role: string | undefined | null): boolean => ['admin', 'supervisor', 'staff'].includes(String(role));
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
export const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' as const },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' as const }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
export const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
export function queueConsentRequests(registrationId: string): void {
|
||||
ensureConsentRequests(registrationId).catch((e: any) => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
type Line = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; quantity: number | string };
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
export function placesAfter(existingLines: Line[] | undefined, requested: Line[] | undefined): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = attendeesUtil.lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
export function pendingCouponLines(event: { eventOptions: { id: string }[] }, resolvedOptions: any[]): any[] {
|
||||
return coupons.buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
export async function priceRequestedOption(eventOption: any, variantId: string | null, qty: number, buyer: any): Promise<{ priceSnapshot: number; appliedTierId: string | null }> {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await pricing.resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await pricing.resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch {
|
||||
const variant = variantId ? (eventOption.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
*/
|
||||
export async function checkOptionStock(option: { id: string; stockLimit: number }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/** Check stock availability for a specific variant (same rules as checkOptionStock). */
|
||||
export async function checkVariantStock(variant: { id: string; eventOptionId: string; stockLimit: number | null }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
export type RequestedOption = { eventOptionId: string; quantity: number; variantId?: string | null; sessionIds?: string[] };
|
||||
export type CreateRegistrationInput = {
|
||||
eventId: string;
|
||||
options: RequestedOption[];
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestPhone?: string;
|
||||
couponCode?: string;
|
||||
attendees?: unknown[];
|
||||
ticketDelivery?: string;
|
||||
waitlistToken?: string;
|
||||
analyticsSessionId?: string;
|
||||
};
|
||||
|
||||
const FULL_INCLUDE = {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Registers someone for an event (online, a guest for a free event, or with a waitlist link):
|
||||
* checks the event is open, prices each line (early-bird, variant, staff price), checks stock
|
||||
* and session places, adds to an existing registration for the same person and event, applies
|
||||
* bundle deals and a coupon, and issues tickets straight away when nothing is owed.
|
||||
*/
|
||||
export async function createRegistration(input: CreateRegistrationInput, actor: Actor) {
|
||||
try {
|
||||
return await create(input, actor);
|
||||
} catch (e: any) {
|
||||
// The helpers (coupons, attendee names, session places…) throw plain Errors written for
|
||||
// people; like v1, those are the caller's problem (400) unless they carry a status.
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateRegistrationInput, actor: Actor) {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = input;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!event.isActive) return fail(400, 'Event is not active');
|
||||
if (event.cashupStatus === 'closed') return fail(400, 'This event is closed and no longer accepting registrations.');
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer: any = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await waitlist.findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) fail(400, 'This waitlist link is not valid.');
|
||||
if (waitlistOffer.status !== 'offered') fail(400, `This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`);
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) fail(400, 'This waitlist offer has expired.');
|
||||
if (actor && actor.id !== waitlistOffer.userId) fail(403, 'This waitlist offer belongs to someone else.');
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
if (event.requiresAuth !== false && !actor && !waitlistOffer) fail(401, 'Authentication required for this event');
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!actor && !waitlistOffer) {
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) fail(400, 'Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId: string;
|
||||
if (actor) {
|
||||
userId = actor.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) return fail(400, 'Name and email are required for unauthenticated registration');
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) fail(409, 'This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
if (match.user) {
|
||||
userId = match.user.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: { id: uuidv4(), name: guestName, email: guestEmail, password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= sessions.registrationClosesAt(event, event.sessions)) fail(400, 'Registration is closed for this event');
|
||||
|
||||
if (!options || !Array.isArray(options) || options.length === 0) fail(400, 'At least one option must be selected');
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = sessions.expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= sessions.wholeCourseClosesAt(event, event.sessions)) {
|
||||
fail(400, 'Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await sessions.assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock. Options are independent of each
|
||||
// other, so resolve them concurrently. The kids check-in and volunteer tickets are handed
|
||||
// out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(actor?.role) ? [] : reservedOptionIds(event));
|
||||
const buyerFor = async () => actor || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option: any) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) fail(400, `Option with ID ${option.eventOptionId} not found for this event`);
|
||||
if (!option.quantity || option.quantity < 1) fail(400, 'Quantity must be at least 1');
|
||||
const qty = option.quantity;
|
||||
|
||||
// (A failing count doesn't block the sale — only a real shortage does.)
|
||||
const stockCheck = await checkOptionStock(eventOption!, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!stockCheck.available) fail(400, `"${eventOption!.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
|
||||
const variantId: string | null = option.variantId || null;
|
||||
if (variantId) {
|
||||
const variant = (eventOption!.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) return fail(400, `Variant not found for option "${eventOption!.name}"`);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!vStock.available) fail(400, `Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
}
|
||||
|
||||
// Variant-level tiers take priority, then option-level tiers, then base prices, then
|
||||
// the staff price for Staff and above.
|
||||
const { priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, await buyerFor());
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot: priceSnapshot ?? eventOption!.price };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
await coupons.previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true },
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = attendeesUtil.validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await attendeesUtil.namedPlacesByKey(existingReg.id) : {},
|
||||
);
|
||||
if (ticketDelivery !== undefined && !(TICKET_DELIVERY as readonly string[]).includes(ticketDelivery)) fail(400, "ticketDelivery must be 'payer' or 'each'");
|
||||
|
||||
let registrationId: string;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null),
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: { id: uuidv4(), registrationOptionId: existing.id, quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null },
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: sessions.mergeSessionIds(existing.sessionIds, opt.sessionIds) }) },
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)), 0);
|
||||
await prisma.registration.update({
|
||||
where: { id: existingReg.id },
|
||||
data: existingReg.status === 'paid' && newItemsCost > 0 ? { status: 'partial_paid', updatedAt: new Date() } : { updatedAt: new Date() },
|
||||
});
|
||||
registrationId = existingReg.id;
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
registrationId = uuidv4();
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: option.quantity, priceSnapshot: option.priceSnapshot, appliedTierId: option.appliedTierId || null }] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await attendeesUtil.createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery: ticketDelivery as 'payer' | 'each' } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError: string | null = null;
|
||||
await coupons.recalculateRegistrationDiscount(registrationId);
|
||||
if (couponCode) {
|
||||
try {
|
||||
await coupons.applyCouponToRegistration({ registrationId, code: couponCode });
|
||||
} catch (e: any) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
const load = () => prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { ...FULL_INCLUDE, couponRedemptions: ACTIVE_COUPONS_INCLUDE, bundleApplications: BUNDLE_APPS_INCLUDE, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
let registration = await load();
|
||||
|
||||
// Nothing owed: mark paid and issue tickets (unless a required form comes first).
|
||||
let emailTicketsAfter = false;
|
||||
try {
|
||||
if (pricing.computeRegistrationTotalDue(registration, new Date()) === 0) {
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
|
||||
if (!(form && form.isRequired)) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registrationId);
|
||||
emailTicketsAfter = true;
|
||||
}
|
||||
registration = { ...registration, status: 'paid' };
|
||||
}
|
||||
} catch {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const notifications = require('../utils/notifications') as { sendRegistrationEmails(id: string): Promise<unknown>; sendRegistrationUpdatedEmails(id: string): Promise<unknown> };
|
||||
const ownerId = registration.userId;
|
||||
void (async () => {
|
||||
try {
|
||||
if (isNewRegistration) await notifications.sendRegistrationEmails(registrationId);
|
||||
else await notifications.sendRegistrationUpdatedEmails(registrationId);
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (emailTicketsAfter) {
|
||||
try {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: unknown, res: unknown): Promise<unknown> };
|
||||
const res: { status(): unknown; json(): void } = { status: () => res, json: () => {} };
|
||||
await emailTickets({ user: { id: ownerId }, body: { registrationId } }, res);
|
||||
} catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics') as { recordSubmit(reg: unknown, sessionId: string): Promise<unknown> };
|
||||
recordSubmit(registration, analyticsSessionId).catch((e: any) => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
return { registration: pricing.attachComputedTotals(registration), isNew: isNewRegistration, couponError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a registration: the owner (not after the event, not once anything is paid) or an
|
||||
* admin. Releases its coupon and offers its places to the waitlist.
|
||||
*/
|
||||
export async function cancelRegistration(registrationId: string, actor: { id: string; role: Role }, ip?: string) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { event: { select: { endDate: true } } },
|
||||
});
|
||||
if (!registration) return fail(404, 'Registration not found');
|
||||
|
||||
if (registration.userId !== actor.id && actor.role !== 'admin') fail(403, 'Not authorized to cancel this registration');
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit block)
|
||||
if (actor.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
try {
|
||||
await assertEventOpen(registration.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
if (registration.status === 'cancelled') fail(400, 'Registration is already cancelled');
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (actor.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({ where: { registrationId: registration.id, amount: { gt: 0 } } });
|
||||
if (paymentCount > 0) fail(403, 'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
|
||||
const updated = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'cancelled', updatedAt: new Date() } });
|
||||
await coupons.releaseRegistrationCoupons(registrationId);
|
||||
waitlist.queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== actor.id) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_cancelled', targetType: 'Registration', targetId: registrationId,
|
||||
metadata: { registrationOwnerId: registration.userId }, ip,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
|
||||
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
|
||||
|
||||
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
|
||||
// includes that day. `search` matches the person (name or email), the target type or id,
|
||||
// or the IP.
|
||||
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (role) where.actorRole = role;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
if (from) where.createdAt.gte = startOf(from);
|
||||
if (to) {
|
||||
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
|
||||
else where.createdAt.lte = new Date(to);
|
||||
}
|
||||
}
|
||||
const q = String(search || '').trim().slice(0, 100);
|
||||
if (q) {
|
||||
const contains = { contains: q, mode: 'insensitive' };
|
||||
where.OR = [
|
||||
{ actor: { name: contains } },
|
||||
{ actor: { email: contains } },
|
||||
{ targetType: contains },
|
||||
{ targetId: { startsWith: q.replace(/^#/, '') } },
|
||||
{ ip: { startsWith: q } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
|
||||
const where = auditLogWhere(filters);
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Bulk actions on registrations (1.49.1): the registrations list lets a supervisor or admin tick
|
||||
* many and act on them at once — mark paid, cancel, move to another ticket, put in a room or
|
||||
* group, or send a message.
|
||||
*
|
||||
* Each action runs the same code as doing it one at a time (the controllers' own handlers, called
|
||||
* in-process), so every rule still applies: closed events, payment caps and overpayments becoming
|
||||
* donations, tickets and emails on payment, coupons released and the waitlist offered on
|
||||
* cancelling, pricing on a ticket change, room capacity and gender, and the privacy rules and
|
||||
* footers on messages. Results come back per registration; one failing doesn't stop the rest.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const MAX = 500;
|
||||
const ACTIONS = ['markPaid', 'cancel', 'moveOption', 'allocate', 'message'];
|
||||
const METHODS = ['cash', 'eft', 'card', 'other'];
|
||||
|
||||
function bulkError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Calls an Express handler in-process; resolves to { ok, status, body }. */
|
||||
function call(handler, req) {
|
||||
return new Promise((resolve) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
|
||||
send(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
|
||||
};
|
||||
Promise.resolve(handler({ query: {}, params: {}, body: {}, headers: {}, ip: req.ip, ...req }, res))
|
||||
.catch(e => resolve({ ok: false, status: 500, body: { message: e?.message || String(e) } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Who is asking, with their request's headers and socket (so audit entries keep the real IP). */
|
||||
const from = req => ({ user: req.user, headers: req.headers || {}, socket: req.socket });
|
||||
|
||||
const messageOf = r => r.body?.message || (r.ok ? 'Done' : `Failed (${r.status})`);
|
||||
|
||||
async function markPaid(req, regs, { method, paidAt }) {
|
||||
if (!METHODS.includes(method)) throw bulkError('Choose how it was paid (cash, EFT, card or other)');
|
||||
const { createPayment } = require('../controllers/paymentController');
|
||||
const { refreshPricingForRegistration, computeRegistrationTotalDue } = require('./pricing');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled — not paid' }); continue; }
|
||||
try { await refreshPricingForRegistration(r.id); } catch { /* the payment code refreshes too */ }
|
||||
const fresh = await prisma.registration.findUnique({ where: { id: r.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } });
|
||||
const owed = Math.round((computeRegistrationTotalDue(fresh, new Date()) - fresh.payments.reduce((s, p) => s + p.amount, 0)) * 100) / 100;
|
||||
if (owed <= 0) { out.push({ id: r.id, ok: true, skipped: true, message: 'Already paid' }); continue; }
|
||||
const res = await call(createPayment, { ...from(req), body: { amount: owed, method, registrationId: r.id, userId: r.userId, ...(paidAt ? { paidAt } : {}) } });
|
||||
out.push({ id: r.id, ok: res.ok, message: res.ok ? `R${owed.toFixed(2)} recorded` : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cancel(req, regs) {
|
||||
const { cancelRegistration } = require('../controllers/registrationController');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: true, skipped: true, message: 'Already cancelled' }); continue; }
|
||||
const res = await call(cancelRegistration, { ...from(req), params: { id: r.id } });
|
||||
out.push({ id: r.id, ok: res.ok, message: res.ok ? 'Cancelled' : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves every place on `fromOptionId` to `toOptionId`, keeping the same lines, tickets (and their
|
||||
* QR codes) and named people — the editing screen can't take issued tickets away, but a move
|
||||
* doesn't need to. Each moved line is repriced at the new ticket's current price (early birds and
|
||||
* staff prices as for a new registration); the status follows what's now paid against owed, so
|
||||
* moving up shows the difference owing and moving down shows the overpayment. Places on the new
|
||||
* ticket are checked first.
|
||||
*/
|
||||
async function moveOption(req, regs, { fromOptionId, toOptionId }) {
|
||||
if (!fromOptionId || !toOptionId || fromOptionId === toOptionId) throw bulkError('Choose the ticket to move from and the one to move to');
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: [fromOptionId, toOptionId] } }, include: { earlyBirdTiers: true, event: { select: { id: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true } } } });
|
||||
if (options.length !== 2 || options[0].eventId !== options[1].eventId) throw bulkError('Both tickets must belong to the same event');
|
||||
const target = options.find(o => o.id === toOptionId);
|
||||
const event = target.event;
|
||||
if (event.cashupStatus === 'closed') throw bulkError('This event is closed. Reopen it (admin only) before making changes.');
|
||||
if ([event.kidsOptionId, event.volunteerOptionId].includes(toOptionId)) throw bulkError('The kids and volunteer tickets are handed out by staff, not moved to');
|
||||
const { resolveOptionPrice } = require('./pricing');
|
||||
const { remainingFor } = require('./stock');
|
||||
const { updateRegistrationStatus } = require('../payments/webhook');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.eventId !== event.id) { out.push({ id: r.id, ok: false, message: 'Not for this event' }); continue; }
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled' }); continue; }
|
||||
if (r.seasonPassPurchaseId) { out.push({ id: r.id, ok: false, message: 'Part of a season pass — tickets can\'t be changed' }); continue; }
|
||||
const lines = await prisma.registrationOption.findMany({ where: { registrationId: r.id, eventOptionId: fromOptionId, sessionId: null }, select: { id: true, quantity: true } });
|
||||
if (!lines.length) { out.push({ id: r.id, ok: true, skipped: true, message: 'No such ticket to move' }); continue; }
|
||||
const qty = lines.reduce((s, l) => s + l.quantity, 0);
|
||||
const left = await remainingFor(target, null);
|
||||
if (left !== null && left < qty) { out.push({ id: r.id, ok: false, message: `Only ${left} place${left === 1 ? '' : 's'} left on ${target.name}` }); continue; }
|
||||
try {
|
||||
const owner = await prisma.user.findUnique({ where: { id: r.userId }, select: { role: true } });
|
||||
const { price, tierId } = await resolveOptionPrice(target, qty, owner);
|
||||
await prisma.$transaction(async tx => {
|
||||
for (const line of lines) {
|
||||
await tx.registrationOption.update({ where: { id: line.id }, data: { eventOptionId: toOptionId, variantId: null, priceSnapshot: price, appliedTierId: tierId || null } });
|
||||
await tx.registrationOptionTranche.deleteMany({ where: { registrationOptionId: line.id } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: line.quantity, priceSnapshot: price, appliedTierId: tierId || null } });
|
||||
await tx.attendee.updateMany({ where: { registrationOptionId: line.id }, data: { eventOptionId: toOptionId, variantId: null } });
|
||||
}
|
||||
});
|
||||
const updated = await updateRegistrationStatus(r.id);
|
||||
const diff = Math.round(((updated.totalDue || 0) - (updated.totalPaid || 0)) * 100) / 100;
|
||||
out.push({ id: r.id, ok: true, message: diff > 0 ? `Moved — R${diff.toFixed(2)} now owing` : diff < 0 ? `Moved — R${(-diff).toFixed(2)} overpaid` : 'Moved' });
|
||||
} catch (e) {
|
||||
out.push({ id: r.id, ok: false, message: e?.message || 'Couldn\'t move' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function allocate(req, regs, { setId, groupId }) {
|
||||
if (!setId || !groupId) throw bulkError('Choose the group to put them in');
|
||||
const { assign } = require('../controllers/allocationController');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
const people = await prisma.attendee.findMany({ where: { registrationId: r.id }, select: { id: true, name: true } });
|
||||
if (!people.length) { out.push({ id: r.id, ok: false, message: 'No named people on this registration to place' }); continue; }
|
||||
const fails = [];
|
||||
for (const a of people) {
|
||||
const res = await call(assign, { ...from(req), params: { id: setId }, body: { attendeeId: a.id, groupId } });
|
||||
if (!res.ok) fails.push(`${a.name}: ${messageOf(res)}`);
|
||||
}
|
||||
out.push({ id: r.id, ok: fails.length === 0, message: fails.length ? fails.join('; ') : `${people.length} placed` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function message(req, regs, { channel, subject, text }) {
|
||||
if (!['email', 'whatsapp'].includes(channel)) throw bulkError('Choose email or WhatsApp');
|
||||
if (!String(text || '').trim()) throw bulkError('Write the message');
|
||||
if (channel === 'email' && !String(subject || '').trim()) throw bulkError('Give the email a subject');
|
||||
const { emailEventAttendees, whatsappEventAttendees } = require('../controllers/eventController');
|
||||
const byEvent = new Map();
|
||||
for (const r of regs) byEvent.set(r.eventId, [...(byEvent.get(r.eventId) || []), r.id]);
|
||||
const out = [];
|
||||
for (const [eventId, ids] of byEvent) {
|
||||
// An event update to the people ticked (they registered, so it goes even to those who opted out of news).
|
||||
const body = channel === 'email'
|
||||
? { subject, text, kind: 'update', filter: { registrationIds: ids } }
|
||||
: { message: text, kind: 'update', filter: { registrationIds: ids } };
|
||||
const res = await call(channel === 'email' ? emailEventAttendees : whatsappEventAttendees, { ...from(req), params: { id: eventId }, body });
|
||||
for (const id of ids) out.push({ id, ok: res.ok, message: res.ok ? `Sent with ${res.body?.sent ?? res.body?.matched ?? ids.length} for this event` : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one bulk action. Returns { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }.
|
||||
*/
|
||||
async function runBulk(req, { action, registrationIds, ...params }) {
|
||||
if (!ACTIONS.includes(action)) throw bulkError('Unknown bulk action');
|
||||
const ids = [...new Set((Array.isArray(registrationIds) ? registrationIds : []).map(String))];
|
||||
if (!ids.length) throw bulkError('Tick at least one registration');
|
||||
if (ids.length > MAX) throw bulkError(`At most ${MAX} at a time`);
|
||||
// Read-only licence: no new payments or ticket changes (cancelling and messaging still work).
|
||||
if (['markPaid', 'moveOption'].includes(action)) {
|
||||
const { licenceStatus } = require('../hub/licence');
|
||||
const status = await licenceStatus().catch(() => null);
|
||||
if (status?.readOnly) throw bulkError('This site is read-only (licence), so payments and ticket changes can\'t be recorded.', 403);
|
||||
}
|
||||
const regs = await prisma.registration.findMany({ where: { id: { in: ids } }, include: { registrationOptions: { select: { eventOptionId: true, variantId: true, quantity: true, sessionId: true } } } });
|
||||
const missing = ids.filter(id => !regs.some(r => r.id === id)).map(id => ({ id, ok: false, message: 'Not found' }));
|
||||
const run = { markPaid, cancel, moveOption, allocate, message }[action];
|
||||
const results = [...missing, ...(await run(req, regs, params))];
|
||||
const { logAdminAction } = require('./adminAudit');
|
||||
const { getClientIp } = require('./requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'registrations_bulk', targetType: 'Registration',
|
||||
metadata: { bulk: action, registrations: ids.length, done: results.filter(r => r.ok && !r.skipped).length, failed: results.filter(r => !r.ok).length },
|
||||
ip: getClientIp({ headers: {}, ...req }),
|
||||
});
|
||||
return {
|
||||
action,
|
||||
done: results.filter(r => r.ok && !r.skipped).length,
|
||||
skipped: results.filter(r => r.skipped).length,
|
||||
failed: results.filter(r => !r.ok).length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { runBulk, ACTIONS, MAX };
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* The home dashboard for staff, supervisors and admins (1.49.0): what's on today, money in, and
|
||||
* what's waiting for someone — plus the search behind the Ctrl+K command palette.
|
||||
*
|
||||
* Everything follows the person's campus scope (utils/campusScope.js): a campus-limited
|
||||
* supervisor sees their campuses' events and church-wide ones. Items only some roles can act on
|
||||
* (cashups, provider payments, privacy requests, webhooks) are shown to those roles only.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { scopeFor, eventWhere } = require('./campusScope');
|
||||
|
||||
const DAY = 864e5;
|
||||
// The same "real money" as the dashboards: everything except legs that apply an earlier donation.
|
||||
const EXCLUDE_DONATION_LEGS = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
|
||||
|
||||
function dayBounds(now = new Date()) {
|
||||
const start = new Date(now); start.setHours(0, 0, 0, 0);
|
||||
return { start, end: new Date(start.getTime() + DAY - 1) };
|
||||
}
|
||||
|
||||
const isAdmin = req => req.user?.role === 'admin';
|
||||
const isSupervisor = req => ['admin', 'supervisor'].includes(req.user?.role);
|
||||
|
||||
/** Events on today (single-date events running today, or a session today), with how it's going. */
|
||||
async function todaysEvents(scopeWhere, now = new Date()) {
|
||||
const { start, end } = dayBounds(now);
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
AND: [scopeWhere, { isActive: true }, {
|
||||
OR: [
|
||||
{ scheduleType: 'single', startDate: { lte: end }, endDate: { gte: start } },
|
||||
{ scheduleType: 'sessions', sessions: { some: { startsAt: { gte: start, lte: end }, cancelledAt: null } } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, location: true, scheduleType: true, kidsCheckIn: true, isHidden: true,
|
||||
campus: { select: { id: true, name: true, slug: true, colour: true } },
|
||||
sessions: { where: { startsAt: { gte: start, lte: end }, cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
|
||||
},
|
||||
});
|
||||
return Promise.all(events.map(async e => {
|
||||
const session = e.sessions[0] || null;
|
||||
const [places, scans, kidsIn, roles] = await Promise.all([
|
||||
prisma.ticket.aggregate({ where: { eventId: e.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }),
|
||||
prisma.ticketUsage.aggregate({ where: { ticket: { eventId: e.id }, scannedAt: { gte: start, lte: end } }, _sum: { quantityRedeemed: true } }),
|
||||
e.kidsCheckIn ? prisma.kidsCheckIn.count({ where: { eventId: e.id, checkedOutAt: null, checkedInAt: { gte: start, lte: end } } }) : Promise.resolve(null),
|
||||
prisma.servingRole.findMany({
|
||||
where: { eventId: e.id, OR: [{ sessionId: null }, ...(session ? [{ sessionId: session.id }] : [])] },
|
||||
select: { needed: true, slots: { select: { checkedInAt: true } } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
id: e.id, title: e.title, location: e.location, campus: e.campus, isHidden: e.isHidden, kidsCheckIn: e.kidsCheckIn,
|
||||
startsAt: session ? session.startsAt : e.startDate, endsAt: session ? session.endsAt : e.endDate,
|
||||
sessionLabel: session ? session.label : null,
|
||||
places: places._sum.quantity || 0,
|
||||
checkedIn: scans._sum.quantityRedeemed || 0,
|
||||
kidsIn,
|
||||
volunteers: roles.length ? {
|
||||
needed: roles.reduce((s, r) => s + r.needed, 0),
|
||||
rostered: roles.reduce((s, r) => s + r.slots.length, 0),
|
||||
checkedIn: roles.reduce((s, r) => s + r.slots.filter(x => x.checkedInAt).length, 0),
|
||||
} : null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** Money in today and over the last 7 days (the person's events, or everything for the unscoped). */
|
||||
async function moneyIn(scope, now = new Date()) {
|
||||
const { start } = dayBounds(now);
|
||||
const weekStart = new Date(start.getTime() - 6 * DAY);
|
||||
const where = from => ({ ...EXCLUDE_DONATION_LEGS, createdAt: { gte: from }, ...(scope ? { event: eventWhere(scope) } : {}) });
|
||||
const [today, week] = await Promise.all([
|
||||
prisma.payment.aggregate({ where: where(start), _sum: { amount: true }, _count: { _all: true } }),
|
||||
prisma.payment.aggregate({ where: where(weekStart), _sum: { amount: true } }),
|
||||
]);
|
||||
return { today: today._sum.amount || 0, todayCount: today._count._all, week: week._sum.amount || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* What's waiting for someone: each { key, label, count, href, tone }, only those with a count.
|
||||
* `href` takes the person to where it's dealt with.
|
||||
*/
|
||||
async function pendingItems(req, scope, now = new Date()) {
|
||||
const upcoming = { startDate: { gte: now } };
|
||||
const scoped = (extra = {}) => ({ AND: [eventWhere(scope), extra] });
|
||||
const soon = new Date(now.getTime() + 14 * DAY);
|
||||
const week = new Date(now.getTime() + 7 * DAY);
|
||||
const items = [];
|
||||
const push = async (key, fn) => {
|
||||
try {
|
||||
const item = await fn();
|
||||
if (item && item.count > 0) items.push({ key, ...item });
|
||||
} catch (e) {
|
||||
console.warn(`[dashboard] ${key}:`, e?.message || e);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
push('unpaid', async () => ({
|
||||
label: 'Unpaid or part-paid registrations for events in the next two weeks',
|
||||
count: await prisma.registration.count({ where: { status: { in: ['pending', 'partial_paid'] }, event: scoped({ startDate: { gte: now, lte: soon }, isActive: true }) } }),
|
||||
href: isAdmin(req) ? '/dashboard/admin/registrations?status=pending' : '/dashboard/supervisor/reports?report=regStatus',
|
||||
tone: 'amber',
|
||||
})),
|
||||
push('consent', async () => {
|
||||
const pending = await prisma.consentRequest.findMany({ where: { status: 'pending', event: scoped(upcoming) }, select: { attendeeId: true }, distinct: ['attendeeId'] });
|
||||
const given = new Set((await prisma.consentRequest.findMany({ where: { status: 'given', attendeeId: { in: pending.map(p => p.attendeeId) } }, select: { attendeeId: true } })).map(g => g.attendeeId));
|
||||
return { label: 'Young people still waiting for parental consent', count: pending.filter(p => !given.has(p.attendeeId)).length, href: '/dashboard/supervisor/consent', tone: 'rose' };
|
||||
}),
|
||||
push('waitlist', async () => ({
|
||||
label: 'People on a waitlist',
|
||||
count: await prisma.waitlistEntry.count({ where: { status: 'waiting', event: scoped(upcoming) } }),
|
||||
href: '/dashboard/supervisor/waitlist',
|
||||
tone: 'blue',
|
||||
})),
|
||||
push('cover', async () => ({
|
||||
label: 'Volunteers asking for someone to cover their shift',
|
||||
count: await prisma.volunteerSwap.count({ where: { status: 'open', role: { event: scoped({ endDate: { gte: now } }) } } }),
|
||||
href: '/dashboard/supervisor/volunteers',
|
||||
tone: 'violet',
|
||||
})),
|
||||
push('roster', async () => {
|
||||
const roles = await prisma.servingRole.findMany({
|
||||
where: {
|
||||
event: scoped({ isActive: true }),
|
||||
OR: [{ session: { startsAt: { gte: now, lte: week } } }, { sessionId: null, event: { startDate: { gte: now, lte: week } } }],
|
||||
},
|
||||
select: { needed: true, _count: { select: { slots: true } } },
|
||||
});
|
||||
return { label: 'Serving roles in the next week that still need people', count: roles.filter(r => r._count.slots < r.needed).length, href: '/dashboard/supervisor/volunteers', tone: 'violet' };
|
||||
}),
|
||||
isAdmin(req) && push('cashup', async () => ({
|
||||
label: 'Past events still to cash up',
|
||||
count: await prisma.event.count({ where: { cashupStatus: 'open', endDate: { lt: new Date(now.getTime() - DAY), gte: new Date(now.getTime() - 120 * DAY) }, payments: { some: {} } } }),
|
||||
href: '/dashboard/admin/cashup',
|
||||
tone: 'green',
|
||||
})),
|
||||
(isAdmin(req) || (isSupervisor(req) && !scope)) && push('reconcile', async () => ({
|
||||
label: 'Online payments that didn\'t match a registration (reconcile them)',
|
||||
count: await prisma.providerTransaction.count({ where: { reconciled: false, ignored: false } }),
|
||||
href: '/dashboard/supervisor/payments',
|
||||
tone: 'amber',
|
||||
})),
|
||||
isAdmin(req) && push('privacy', async () => ({
|
||||
label: 'Privacy requests to answer',
|
||||
count: await prisma.dataRequest.count({ where: { status: 'open' } }),
|
||||
href: '/dashboard/admin/data-requests',
|
||||
tone: 'rose',
|
||||
})),
|
||||
isAdmin(req) && push('webhooks', async () => ({
|
||||
label: 'Webhook deliveries that failed this week',
|
||||
count: await prisma.webhookDelivery.count({ where: { status: 'failed', createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
|
||||
href: '/dashboard/admin/settings?tab=webhooks',
|
||||
tone: 'rose',
|
||||
})),
|
||||
].filter(Boolean));
|
||||
const order = ['privacy', 'consent', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks'];
|
||||
return items.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
|
||||
}
|
||||
|
||||
async function today(req, now = new Date()) {
|
||||
const scope = await scopeFor(req);
|
||||
const [events, money, pending] = await Promise.all([
|
||||
todaysEvents(eventWhere(scope), now),
|
||||
isSupervisor(req) ? moneyIn(scope, now) : Promise.resolve(null),
|
||||
isSupervisor(req) ? pendingItems(req, scope, now) : Promise.resolve([]),
|
||||
]);
|
||||
return { events, money, pending };
|
||||
}
|
||||
|
||||
// ─── Command palette search ──────────────────────────────────────────────────
|
||||
|
||||
/** "R450", "450", "r 450.50" → 450.5; anything else → null. Pure. */
|
||||
function amountOf(q) {
|
||||
const m = /^r?\s*(\d{1,7}(?:[.,]\d{1,2})?)$/i.exec(String(q || '').trim());
|
||||
return m ? parseFloat(m[1].replace(',', '.')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Results for the command palette: events (everyone with dashboard access), and for supervisors
|
||||
* payments by amount ("R450"); for admins also people and registrations. Each { type, id, title,
|
||||
* subtitle, href }. Up to 5 of each kind.
|
||||
*/
|
||||
async function search(req, rawQuery, now = new Date()) {
|
||||
const q = String(rawQuery || '').trim().slice(0, 80);
|
||||
if (q.length < 2) return [];
|
||||
const scope = await scopeFor(req);
|
||||
const contains = { contains: q, mode: 'insensitive' };
|
||||
const out = [];
|
||||
const supervisor = isSupervisor(req);
|
||||
const fmtDate = d => new Date(d).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { AND: [eventWhere(scope), { title: contains }] },
|
||||
orderBy: { startDate: 'desc' }, take: 5,
|
||||
select: { id: true, title: true, startDate: true, isActive: true, campus: { select: { name: true } } },
|
||||
});
|
||||
for (const e of events) {
|
||||
out.push({
|
||||
type: 'event', id: e.id, title: e.title,
|
||||
subtitle: [fmtDate(e.startDate), e.campus?.name, e.isActive ? null : 'inactive'].filter(Boolean).join(' · '),
|
||||
href: supervisor ? `/dashboard/supervisor/events?edit=${e.id}` : `/events/${e.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const amount = amountOf(q);
|
||||
if (supervisor && amount !== null) {
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: { amount: { in: [amount, -amount] }, createdAt: { gte: new Date(now.getTime() - 365 * DAY) }, ...(scope ? { event: eventWhere(scope) } : {}) },
|
||||
orderBy: { createdAt: 'desc' }, take: 5,
|
||||
select: { id: true, amount: true, method: true, createdAt: true, registrationId: true, user: { select: { name: true } }, event: { select: { title: true } } },
|
||||
});
|
||||
for (const p of payments) {
|
||||
out.push({
|
||||
type: 'payment', id: p.id, title: `R${p.amount.toFixed(2)} — ${p.user?.name || 'Unknown'}`,
|
||||
subtitle: [p.method, p.event?.title, fmtDate(p.createdAt)].filter(Boolean).join(' · '),
|
||||
href: isAdmin(req) && p.registrationId ? `/dashboard/admin/registrations?q=${p.registrationId}` : '/dashboard/supervisor/payments',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin(req)) {
|
||||
const people = await prisma.user.findMany({
|
||||
where: { isActive: true, OR: [{ name: contains }, { email: contains }, { phoneNumber: { contains: q.replace(/\s+/g, '') } }] },
|
||||
orderBy: { name: 'asc' }, take: 5,
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
});
|
||||
for (const u of people) {
|
||||
out.push({ type: 'person', id: u.id, title: u.name, subtitle: [u.email, u.role !== 'user' ? u.role : null].filter(Boolean).join(' · '), href: `/dashboard/admin/users?q=${encodeURIComponent(u.email || u.name)}` });
|
||||
}
|
||||
// A registration by its reference (the start of its id), as on emails and invoices.
|
||||
if (/^[0-9a-f-]{6,36}$/i.test(q)) {
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { id: { startsWith: q.toLowerCase() } }, take: 5,
|
||||
select: { id: true, status: true, user: { select: { name: true } }, event: { select: { title: true } } },
|
||||
});
|
||||
for (const r of regs) {
|
||||
out.push({ type: 'registration', id: r.id, title: `Registration ${r.id.slice(0, 8)} — ${r.user?.name || ''}`.trim(), subtitle: [r.event?.title, r.status].filter(Boolean).join(' · '), href: `/dashboard/admin/registrations?q=${r.id}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { today, todaysEvents, moneyIn, pendingItems, search, amountOf, dayBounds };
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Event cloning (1.47.0). The copy itself is made by the event form in the browser — it opens
|
||||
* "New event" filled in from the source event (details, sessions, options, variants, early-bird
|
||||
* tiers, form, feedback form, sections, notification recipients), so everything can be changed
|
||||
* before saving. This file copies the parts that form doesn't hold, once the new event exists:
|
||||
* allocation sets with their groups, and (admins only) the planned costs.
|
||||
*
|
||||
* Never copied: registrations and everything people did, coupons, attachments, bundles, the kids
|
||||
* check-in setup (kids ticket and rooms) and volunteer roles.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const COST_FIELDS = ['label', 'costType', 'amount', 'paidFromMethod', 'notes'];
|
||||
|
||||
/**
|
||||
* Costs for the new event. A cost tied to a ticket option moves to the new event's option with
|
||||
* the same name; when there's none it stays on the event as a whole.
|
||||
* @param {object[]} costs the source event's EventCost rows (with `eventOption.name`)
|
||||
* @param {object[]} newOptions the new event's options ({ id, name })
|
||||
*/
|
||||
function costCopies(costs, newOptions) {
|
||||
const byName = new Map(newOptions.map(o => [String(o.name).trim().toLowerCase(), o.id]));
|
||||
return costs.map(c => {
|
||||
const data = {};
|
||||
for (const f of COST_FIELDS) data[f] = c[f] ?? null;
|
||||
const name = c.eventOption?.name ? String(c.eventOption.name).trim().toLowerCase() : null;
|
||||
data.eventOptionId = (name && byName.get(name)) || null;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
/** The source's allocation sets worth copying: all but the kids rooms. */
|
||||
function setsToCopy(sets, kidsRoomSetId) {
|
||||
return sets.filter(s => s.id !== kidsRoomSetId);
|
||||
}
|
||||
|
||||
function cloneError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies allocation sets and groups (and costs when `withCosts`) from `fromId` to the just-made
|
||||
* event `toId`. Refused once the new event has registrations or allocation sets, so it can only
|
||||
* fill a fresh copy.
|
||||
* @returns {Promise<{ sets: number, groups: number, costs: number }>}
|
||||
*/
|
||||
async function copyEventSetup(fromId, toId, { withCosts = false } = {}) {
|
||||
if (fromId === toId) throw cloneError("An event can't be copied onto itself");
|
||||
const [from, to] = await Promise.all([
|
||||
prisma.event.findUnique({
|
||||
where: { id: fromId },
|
||||
select: {
|
||||
id: true, kidsRoomSetId: true,
|
||||
allocationSets: { orderBy: { order: 'asc' }, include: { groups: { orderBy: { order: 'asc' } } } },
|
||||
costs: { orderBy: { createdAt: 'asc' }, include: { eventOption: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
prisma.event.findUnique({
|
||||
where: { id: toId },
|
||||
select: { id: true, eventOptions: { select: { id: true, name: true } }, _count: { select: { registrations: true, allocationSets: true } } },
|
||||
}),
|
||||
]);
|
||||
if (!from || !to) throw cloneError('Event not found', 404);
|
||||
if (to._count.registrations > 0 || to._count.allocationSets > 0) {
|
||||
throw cloneError('Setup can only be copied into a new event (this one already has registrations or groups)', 409);
|
||||
}
|
||||
|
||||
const sets = setsToCopy(from.allocationSets, from.kidsRoomSetId);
|
||||
const costs = withCosts ? costCopies(from.costs, to.eventOptions) : [];
|
||||
let groups = 0;
|
||||
await prisma.$transaction(async tx => {
|
||||
for (const s of sets) {
|
||||
const set = await tx.allocationSet.create({ data: { id: uuidv4(), eventId: toId, name: s.name, showOnTicket: s.showOnTicket, order: s.order } });
|
||||
for (const g of s.groups) {
|
||||
await tx.allocationGroup.create({
|
||||
data: {
|
||||
id: uuidv4(), setId: set.id, name: g.name, capacity: g.capacity, gender: g.gender,
|
||||
leaderUserId: g.leaderUserId, order: g.order, minAge: g.minAge, maxAge: g.maxAge, ratio: g.ratio,
|
||||
},
|
||||
});
|
||||
groups++;
|
||||
}
|
||||
}
|
||||
for (const c of costs) await tx.eventCost.create({ data: { id: uuidv4(), eventId: toId, ...c } });
|
||||
});
|
||||
return { sets: sets.length, groups, costs: costs.length };
|
||||
}
|
||||
|
||||
module.exports = { copyEventSetup, costCopies, setsToCopy };
|
||||
@@ -211,6 +211,8 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
// A season pass line is priced at its event's share of the pass, never at the ticket's price.
|
||||
if (registration.seasonPassPurchaseId) return { changed: false };
|
||||
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
|
||||
@@ -463,6 +463,13 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
|
||||
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
|
||||
|
||||
// Event series they followed.
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -485,7 +492,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed, seasonPasses] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -511,6 +518,8 @@ async function exportUserData(userId, db = prisma) {
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
require('./kids').exportFamily(userId, db),
|
||||
require('./volunteers').exportVolunteering(userId, db),
|
||||
require('./series').exportFollows(userId, db),
|
||||
require('./seasonPass').exportPurchases(userId, db),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -554,6 +563,8 @@ async function exportUserData(userId, db = prisma) {
|
||||
dataRequests: requests,
|
||||
kidsFamily: family,
|
||||
volunteering,
|
||||
seriesFollowed,
|
||||
seasonPasses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +625,14 @@ async function dataExportPdf(data) {
|
||||
heading('Serving (volunteer rosters)');
|
||||
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
|
||||
}
|
||||
if (data.seasonPasses?.length) {
|
||||
heading('Season passes');
|
||||
for (const s of data.seasonPasses) para(`${fmt(s.at)} — ${s.quantity} × ${s.pass}, R${Number(s.total).toFixed(2)} (${s.status})`);
|
||||
}
|
||||
if (data.seriesFollowed?.length) {
|
||||
heading('Event series you follow');
|
||||
for (const f of data.seriesFollowed) para(`${f.series} — since ${fmt(f.since)}`);
|
||||
}
|
||||
if (data.feedback.length) {
|
||||
heading('Feedback you gave');
|
||||
for (const f of data.feedback) {
|
||||
|
||||
@@ -24,9 +24,9 @@ const DEFAULT_SECTIONS = [
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in, volunteer rosters and, for children\'s ministry, kids check-in.',
|
||||
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9 — they process payments and deliver messages for us, and any other system we connect (such as church-management software) gets only the updates we chose.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
|
||||
@@ -53,6 +53,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
|
||||
'• Your home campus, if the organisation has more than one campus and you choose one.',
|
||||
'• Serving (if you volunteer): the roles and shifts you sign up for or are given, requests to pass a shift on and who took it, when you checked in, and the hours you served.',
|
||||
'• Event series you follow, and season passes you buy (a season pass registers you for each event on it, so each shows in your registrations).',
|
||||
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
|
||||
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
@@ -90,6 +92,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
|
||||
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
|
||||
'• Show you the events of your home campus first, if the organisation has more than one campus.',
|
||||
'• Run volunteer rosters: remind you the day before you serve, pass your shift on to someone who accepts it when you can\'t make it, and check you in when you arrive.',
|
||||
'• 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.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
@@ -106,7 +110,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
|
||||
'• Session reminders, if the event sends them.',
|
||||
'• Session reminders, if the event sends them, and volunteer reminders the day before you serve.',
|
||||
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
@@ -133,6 +137,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'• Copies of the updates sent to connected systems (webhooks, section 9) are kept for 30 days so problems can be checked, then deleted — and straight away when your account is deleted.',
|
||||
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
@@ -161,10 +166,12 @@ 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 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.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
|
||||
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
|
||||
'• Staff running a volunteer roster see who is serving in each role, their contact details, check-ins and hours. If you ask for someone to take over your shift, your first name, the role and any note you add are shown to the event\x27s other volunteers and others who could take it over.',
|
||||
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
@@ -205,6 +212,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
|
||||
'• cross_events_dismissed_notices — notices from the software provider (such as planned maintenance) that you have closed.',
|
||||
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
@@ -230,6 +238,7 @@ const DEFAULT_SECTIONS = [
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'You are asked to agree to this notice when you create an account, or when you activate an account that was made for you (for example when someone registered you, or at the door). If you have never agreed to it — such as an account from before we asked — we ask once, the next time you log in.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
@@ -253,6 +262,49 @@ const DEFAULT_SECTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Changes to the built-in text, newest last. A site that published its own version keeps its own
|
||||
* text, so the editor lists the changes made since the revision it last reviewed
|
||||
* (`privacy_notice_builtin_reviewed`) and offers the new text section by section.
|
||||
*/
|
||||
const BUILT_IN_UPDATES = [
|
||||
{
|
||||
revision: '1.48.1',
|
||||
summary: [
|
||||
'Volunteer rosters: what is kept about people who serve, reminders, and who sees the roster.',
|
||||
'Following an event series, and season passes.',
|
||||
'Webhooks: other systems the organisation connects, what they receive, and that copies are kept 30 days.',
|
||||
'How people agree to the notice: at sign-up, at account activation, or once after login if they never have.',
|
||||
'A browser-storage item for closed provider notices.',
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
function revisionLess(a, b) {
|
||||
const pa = String(a || '0').split('.').map(Number);
|
||||
const pb = String(b || '0').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in text changes this site hasn't reviewed yet. None while nothing is published (the site
|
||||
* shows the built-in text, so it is always current).
|
||||
*/
|
||||
async function builtInUpdates(db = prisma) {
|
||||
if (!(await latestNotice(db))) return [];
|
||||
const row = await db.appSetting.findUnique({ where: { key: REVIEWED_KEY } });
|
||||
return BUILT_IN_UPDATES.filter(u => !row?.value || revisionLess(row.value, u.revision));
|
||||
}
|
||||
|
||||
/** The admin has gone through the built-in changes (taken what they want, or not). */
|
||||
async function markBuiltInReviewed(db = prisma) {
|
||||
await db.appSetting.upsert({ where: { key: REVIEWED_KEY }, update: { value: BUILT_IN_REVISION }, create: { key: REVIEWED_KEY, value: BUILT_IN_REVISION } });
|
||||
}
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
@@ -340,11 +392,17 @@ async function requiredVersion(db = prisma) {
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
/**
|
||||
* Whether someone must accept the notice before carrying on, and why: 'first' — they never
|
||||
* accepted any version (an account made for them by staff, at the door or by someone who
|
||||
* registered them, or from before acceptances were recorded); 'updated' — a version marked as a
|
||||
* significant change came out after the one they accepted. false when nothing is needed.
|
||||
*/
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
if (user.noticeVersionAccepted == null) return 'first';
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
return user.noticeVersionAccepted < required ? 'updated' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,6 +505,11 @@ async function publish({ sections, changeSummary, requiresAcceptance, publishedB
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
BUILT_IN_UPDATES,
|
||||
BUILT_IN_REVISION,
|
||||
builtInUpdates,
|
||||
markBuiltInReviewed,
|
||||
revisionLess,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
|
||||
* with one click; one per page can be their default (applied when the page opens). Stored per
|
||||
* person on the server, so they follow them to any device. Only their own are ever read or changed.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// The list pages that have saved filters, and who can open them.
|
||||
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
|
||||
const MAX_PER_PAGE = 30;
|
||||
const MAX_PARAMS_BYTES = 4000;
|
||||
|
||||
function filterError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function cleanPage(page) {
|
||||
const p = String(page || '');
|
||||
if (!PAGES[p]) throw filterError('Unknown list page');
|
||||
return p;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim().replace(/\s+/g, ' ');
|
||||
if (!n) throw filterError('Give the filter a name');
|
||||
return n.slice(0, 60);
|
||||
}
|
||||
|
||||
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
|
||||
function cleanParams(params) {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
|
||||
if (typeof v === 'string') out[k] = v.slice(0, 500);
|
||||
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
|
||||
else if (typeof v === 'boolean') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
|
||||
}
|
||||
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
|
||||
return out;
|
||||
}
|
||||
|
||||
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
|
||||
|
||||
async function list(userId, page) {
|
||||
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
|
||||
async function save(userId, { page, name, params, isDefault = false }) {
|
||||
const p = cleanPage(page);
|
||||
const n = cleanName(name);
|
||||
const data = cleanParams(params);
|
||||
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
|
||||
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
|
||||
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
|
||||
}
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
|
||||
const row = existing
|
||||
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
|
||||
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
|
||||
return view(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function own(userId, id) {
|
||||
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
|
||||
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Rename, or make (or stop being) the default. */
|
||||
async function update(userId, id, { name, isDefault }) {
|
||||
const row = await own(userId, id);
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
|
||||
try {
|
||||
return view(await tx.savedFilter.update({
|
||||
where: { id: row.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(userId, id) {
|
||||
const row = await own(userId, id);
|
||||
await prisma.savedFilter.delete({ where: { id: row.id } });
|
||||
}
|
||||
|
||||
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Season passes (1.47.3): one price for a ticket at each of several events in a series.
|
||||
*
|
||||
* - A supervisor picks the events on the pass, and which ticket (and variant) at each.
|
||||
* - Buying registers the buyer at every event on it at once (`quantity` places each) and starts
|
||||
* one online checkout for the total. A free pass gives the tickets straight away.
|
||||
* - The price is split across the events in proportion to their normal ticket prices (whole
|
||||
* cents, `splitCents`), and each registration's line is priced at its event's share, so every
|
||||
* event's cashup and reports count only its part. Those lines are never repriced, take no
|
||||
* coupons, and can't be edited.
|
||||
* - The payment comes back as one provider payment; it's recorded as one Payment per event
|
||||
* (`recordPassPayment`), all linked to the purchase.
|
||||
* - A pass stops selling when the first event on it starts, at its sale end, or when switched off.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
function passError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const toCents = n => Math.round(Number(n || 0) * 100);
|
||||
|
||||
/**
|
||||
* Splits `totalCents` in proportion to `weights` (whole cents that add up exactly; the cents
|
||||
* left over by rounding down go to the largest remainders). All weights zero → equal parts.
|
||||
* Pure, for tests.
|
||||
*/
|
||||
function splitCents(totalCents, weights) {
|
||||
if (!weights.length) return [];
|
||||
const w = weights.map(x => Math.max(0, Number(x) || 0));
|
||||
const sum = w.reduce((s, x) => s + x, 0);
|
||||
const shares = sum > 0 ? w.map(x => (totalCents * x) / sum) : w.map(() => totalCents / w.length);
|
||||
const out = shares.map(Math.floor);
|
||||
let left = totalCents - out.reduce((s, x) => s + x, 0);
|
||||
const order = shares.map((s, i) => ({ i, frac: s - Math.floor(s) })).sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||||
for (let k = 0; left > 0; k = (k + 1) % order.length, left--) out[order[k].i]++;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The normal price of one place on an item: the variant's own price, else the option's. */
|
||||
function normalPrice(item) {
|
||||
const v = item.variant;
|
||||
return Number(v && v.price !== null && v.price !== undefined ? v.price : item.eventOption?.price || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each item's share of the pass price (rand, one pass), in proportion to its normal price.
|
||||
* Pure, for tests: items need eventOption.price (and variant.price when there is one).
|
||||
*/
|
||||
function itemShares(price, items) {
|
||||
return splitCents(toCents(price), items.map(i => toCents(normalPrice(i)))).map(c => c / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a payment goes to each registration: each gets what it still owes, in order, and
|
||||
* anything over goes to the last one. `dues` in rand. Pure, for tests.
|
||||
*/
|
||||
function allocate(dues, amount) {
|
||||
let left = toCents(amount);
|
||||
const out = dues.map(d => {
|
||||
const take = Math.min(left, Math.max(0, toCents(d)));
|
||||
left -= take;
|
||||
return take;
|
||||
});
|
||||
if (left > 0 && out.length) out[out.length - 1] += left;
|
||||
return out.map(c => c / 100);
|
||||
}
|
||||
|
||||
const ITEM_INCLUDE = {
|
||||
event: {
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, isActive: true, seriesId: true, campusId: true,
|
||||
registrationDeadline: true, scheduleType: true, attendanceMode: true, lateJoinEnabled: true, lateJoinUntilSessionId: true,
|
||||
requiresParentalConsent: true, requiresRegistration: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true,
|
||||
sessions: { select: { id: true, startsAt: true, endsAt: true, cancelledAt: true } },
|
||||
},
|
||||
},
|
||||
eventOption: { select: { id: true, name: true, price: true, stockLimit: true, eventId: true, sessionMode: true } },
|
||||
variant: { select: { id: true, name: true, price: true, stockLimit: true, eventOptionId: true } },
|
||||
};
|
||||
const PASS_INCLUDE = { items: { orderBy: { order: 'asc' }, include: ITEM_INCLUDE }, series: { select: { id: true, name: true, slug: true } } };
|
||||
|
||||
/** Why a pass can't be bought right now, or null. */
|
||||
function notOnSale(pass, now = new Date()) {
|
||||
if (!pass.isActive) return 'This pass isn\'t on sale.';
|
||||
if (!pass.items.length) return 'This pass has no events yet.';
|
||||
if (pass.saleEndsAt && new Date(pass.saleEndsAt) <= now) return 'This pass is no longer on sale.';
|
||||
const { wholeCourseClosesAt } = require('./sessions');
|
||||
for (const i of pass.items) {
|
||||
if (!i.event.isActive || i.event.cashupStatus === 'closed') return `${i.event.title} isn't taking registrations.`;
|
||||
if (new Date(i.event.startDate) <= now) return 'This pass stopped selling when its first event started.';
|
||||
if (wholeCourseClosesAt(i.event, i.event.sessions) <= now) return `Registration for ${i.event.title} has closed.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A pass as shown to buyers and supervisors: its events with normal prices and shares. */
|
||||
function passView(pass, now = new Date()) {
|
||||
const shares = itemShares(pass.price, pass.items);
|
||||
const normalValue = Math.round(pass.items.reduce((s, i) => s + normalPrice(i), 0) * 100) / 100;
|
||||
return {
|
||||
id: pass.id, seriesId: pass.seriesId, name: pass.name, description: pass.description, price: pass.price,
|
||||
isActive: pass.isActive, saleEndsAt: pass.saleEndsAt, series: pass.series,
|
||||
normalValue, saving: Math.max(0, Math.round((normalValue - pass.price) * 100) / 100),
|
||||
unavailable: notOnSale(pass, now),
|
||||
items: pass.items.map((i, k) => ({
|
||||
id: i.id, eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId,
|
||||
eventTitle: i.event.title, startDate: i.event.startDate, endDate: i.event.endDate, campusId: i.event.campusId,
|
||||
ticket: i.variant ? `${i.eventOption.name} — ${i.variant.name}` : i.eventOption.name,
|
||||
normalPrice: normalPrice(i), share: shares[k],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setting passes up ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks a create/update body against the series' events: each item is an event in the series
|
||||
* with one of its tickets (not the staff-only kids/volunteer ones, not a per-session ticket) and
|
||||
* no parental consent (a pass can't collect children's details). Returns the data to save and the
|
||||
* events on it (for the campus check).
|
||||
*/
|
||||
async function cleanPassInput(body, seriesId, current = null, db = prisma) {
|
||||
const data = {};
|
||||
if (body.name !== undefined || !current) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw passError('Give the pass a name');
|
||||
data.name = name.slice(0, 120);
|
||||
}
|
||||
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
|
||||
if (body.price !== undefined || !current) {
|
||||
const price = Number(body.price);
|
||||
if (!(price >= 0) || !Number.isFinite(price)) throw passError('Enter the pass price (0 for free)');
|
||||
data.price = Math.round(price * 100) / 100;
|
||||
}
|
||||
if (body.isActive !== undefined) data.isActive = body.isActive !== false;
|
||||
if (body.saleEndsAt !== undefined) {
|
||||
const d = body.saleEndsAt ? new Date(body.saleEndsAt) : null;
|
||||
if (d && isNaN(d.getTime())) throw passError('The sale end date isn\'t valid');
|
||||
data.saleEndsAt = d;
|
||||
}
|
||||
|
||||
let items = null;
|
||||
if (body.items !== undefined || !current) {
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) throw passError('Choose at least one event for the pass');
|
||||
const seen = new Set();
|
||||
items = [];
|
||||
for (const [order, raw] of body.items.entries()) {
|
||||
const eventId = String(raw?.eventId || '');
|
||||
if (seen.has(eventId)) throw passError('Each event can only be on a pass once');
|
||||
seen.add(eventId);
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, seriesId: true, campusId: true, requiresParentalConsent: true, requiresRegistration: true, kidsOptionId: true, volunteerOptionId: true, scheduleType: true, attendanceMode: true, eventOptions: { select: { id: true, sessionMode: true, variants: { select: { id: true } } } } },
|
||||
});
|
||||
if (!event || event.seriesId !== seriesId) throw passError('Every event on a pass must be in its series');
|
||||
if (event.requiresParentalConsent) throw passError(`${event.title} is a youth event (parental consent), so it can't be on a pass`);
|
||||
if (event.requiresRegistration === false) throw passError(`${event.title} doesn't take registrations`);
|
||||
const option = event.eventOptions.find(o => o.id === raw.eventOptionId);
|
||||
if (!option || [event.kidsOptionId, event.volunteerOptionId].includes(option.id)) throw passError(`Choose a ticket for ${event.title}`);
|
||||
if (event.scheduleType === 'sessions' && event.attendanceMode === 'pick' && option.sessionMode === 'pick') {
|
||||
throw passError(`${event.title}: choose a ticket for all sessions, not a per-session one`);
|
||||
}
|
||||
const variantId = raw.variantId ? String(raw.variantId) : null;
|
||||
if (variantId && !option.variants.some(v => v.id === variantId)) throw passError(`That variant isn't one of the ticket's at ${event.title}`);
|
||||
items.push({ eventId, eventOptionId: option.id, variantId, order, campusId: event.campusId });
|
||||
}
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function loadPass(id, db = prisma) {
|
||||
const pass = await db.seasonPass.findUnique({ where: { id }, include: PASS_INCLUDE });
|
||||
if (!pass) throw passError('Season pass not found', 404);
|
||||
return pass;
|
||||
}
|
||||
|
||||
// ─── Buying ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Buys `quantity` passes for `userId`: a registration at each event (lines priced at the share),
|
||||
* then one checkout for the total — or, for a free pass, the tickets straight away.
|
||||
* @returns {Promise<{ purchaseId, total, paid, redirectUrl, provider, message }>}
|
||||
*/
|
||||
async function buyPass(passId, userId, { quantity = 1, provider = null, successUrl, cancelUrl, failureUrl } = {}) {
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!(qty >= 1 && qty <= 20)) throw passError('Choose how many passes (1 to 20)');
|
||||
const pass = await loadPass(passId);
|
||||
const reason = notOnSale(pass);
|
||||
if (reason) throw passError(reason, 409);
|
||||
const shares = itemShares(pass.price, pass.items);
|
||||
const total = Math.round(pass.price * qty * 100) / 100;
|
||||
const free = total === 0;
|
||||
|
||||
const { remainingFor } = require('./stock');
|
||||
const purchase = await prisma.$transaction(async tx => {
|
||||
// One buyer at a time per pass, so the last places can't be sold twice through passes.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'seasonpass:' + pass.id}))`;
|
||||
for (const i of pass.items) {
|
||||
const left = await remainingFor(i.eventOption, i.variant, { db: tx });
|
||||
if (left !== null && left < qty) throw passError(`${i.event.title} has ${left === 0 ? 'no places' : `only ${left} place${left === 1 ? '' : 's'}`} left for ${i.variant ? i.variant.name : i.eventOption.name}`, 409);
|
||||
}
|
||||
const created = await tx.seasonPassPurchase.create({ data: { passId: pass.id, userId, quantity: qty, total, status: free ? 'paid' : 'pending' } });
|
||||
for (const [k, i] of pass.items.entries()) {
|
||||
const registration = await tx.registration.create({
|
||||
data: { userId, eventId: i.eventId, status: free ? 'paid' : 'pending', seasonPassPurchaseId: created.id },
|
||||
});
|
||||
const line = await tx.registrationOption.create({
|
||||
data: { registrationId: registration.id, eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: qty, priceSnapshot: shares[k] },
|
||||
});
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: qty, priceSnapshot: shares[k] } });
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
const registrations = await prisma.registration.findMany({ where: { seasonPassPurchaseId: purchase.id }, select: { id: true } });
|
||||
if (free) {
|
||||
const { generateTicketsForRegistration } = require('./ticketUtils');
|
||||
for (const r of registrations) await generateTicketsForRegistration(r.id).catch(e => console.error('[season-pass] tickets:', e?.message || e));
|
||||
emailTicketsFor(userId, registrations.map(r => r.id));
|
||||
return { purchaseId: purchase.id, total, paid: true, redirectUrl: null, provider: null, message: 'Your tickets are ready.' };
|
||||
}
|
||||
return { purchaseId: purchase.id, total, paid: false, ...(await startPassCheckout(purchase.id, userId, { provider, successUrl, cancelUrl, failureUrl })) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the online checkout for what's still owed on a purchase. When online
|
||||
* payment isn't possible (none set up, or under the minimum) the places stay booked unpaid and
|
||||
* the buyer is told to pay the church.
|
||||
*/
|
||||
async function startPassCheckout(purchaseId, userId, { provider = null, successUrl, cancelUrl, failureUrl } = {}) {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({
|
||||
where: { id: purchaseId },
|
||||
include: {
|
||||
pass: { select: { name: true } },
|
||||
user: { select: { name: true, email: true } },
|
||||
registrations: { include: { event: { select: { title: true } }, registrationOptions: { include: { tranches: true } }, payments: true } },
|
||||
},
|
||||
});
|
||||
if (!purchase || purchase.userId !== userId) throw passError('Purchase not found', 404);
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const owed = purchase.registrations
|
||||
.filter(r => r.status !== 'cancelled')
|
||||
.map(r => ({ r, due: Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)) }))
|
||||
.filter(x => x.due > 0.004);
|
||||
const amountCents = owed.reduce((s, x) => s + toCents(x.due), 0);
|
||||
if (amountCents <= 0) throw passError('This pass is already paid');
|
||||
|
||||
const payments = require('../payments');
|
||||
try {
|
||||
const checkout = await payments.createCheckout(provider, {
|
||||
amountCents,
|
||||
metadata: { type: 'season_pass', purchaseId: purchase.id, userId, passName: purchase.pass.name },
|
||||
lineItems: owed.map(x => ({ name: `${purchase.pass.name}: ${x.r.event.title}`, quantity: 1, unitCents: toCents(x.due) })),
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
payer: { name: purchase.user?.name || '', email: purchase.user?.email || '' },
|
||||
description: purchase.pass.name,
|
||||
idempotencyKey: `pass_${purchase.id}_${amountCents}_${Date.now()}`,
|
||||
});
|
||||
await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { checkoutId: checkout.checkoutId } });
|
||||
return { redirectUrl: checkout.redirectUrl, provider: checkout.provider, message: null };
|
||||
} catch (e) {
|
||||
// Online payment not possible (switched off, not set up, or under the minimum): keep the places.
|
||||
if (e.statusCode && (e.statusCode < 500 || e.statusCode === 503)) {
|
||||
return { redirectUrl: null, provider: null, message: `Your places are booked. ${e.message}` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function emailTicketsFor(userId, registrationIds) {
|
||||
(async () => {
|
||||
const { emailTickets } = require('../controllers/ticketController');
|
||||
for (const registrationId of registrationIds) {
|
||||
const mockReq = { user: { id: userId }, body: { registrationId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('[season-pass] emailing tickets:', e?.message || e); }
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pass checkout was paid (webhook or status check): one Payment per registration on the
|
||||
* purchase for what it owed, all linked to the purchase, then each registration's status and
|
||||
* tickets. Idempotent on the provider's payment id (the first Payment carries it).
|
||||
*/
|
||||
async function recordPassPayment(providerId, event, body) {
|
||||
const { externalId, amountCents, status, metadata = {}, methodType } = event.transaction;
|
||||
const webhook = require('../payments/webhook');
|
||||
const existing = await prisma.payment.findFirst({ where: { externalId } });
|
||||
if (existing) {
|
||||
await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existing.id }).catch(() => {});
|
||||
return { status: 'already_processed', paymentId: existing.id, externalId };
|
||||
}
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({
|
||||
where: { id: String(metadata.purchaseId || '') },
|
||||
include: { registrations: { orderBy: { createdAt: 'asc' }, include: { registrationOptions: { include: { tranches: true } }, payments: true } } },
|
||||
});
|
||||
if (!purchase) throw passError(`Season pass purchase ${metadata.purchaseId} not found`, 404);
|
||||
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const regs = purchase.registrations.filter(r => r.status !== 'cancelled');
|
||||
const dues = regs.map(r => Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)));
|
||||
const parts = allocate(dues, (amountCents || 0) / 100);
|
||||
const method = methodType || 'card';
|
||||
const paymentStatus = status === 'succeeded' ? 'completed' : status;
|
||||
|
||||
const created = [];
|
||||
for (const [k, r] of regs.entries()) {
|
||||
if (!(parts[k] > 0)) continue;
|
||||
created.push(await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), amount: parts[k], method, provider: providerId, status: paymentStatus,
|
||||
externalId: created.length === 0 ? externalId : null,
|
||||
registrationId: r.id, eventId: r.eventId, userId: purchase.userId, recordedById: purchase.userId,
|
||||
seasonPassPurchaseId: purchase.id,
|
||||
},
|
||||
}));
|
||||
}
|
||||
const paidIds = [];
|
||||
for (const r of regs) {
|
||||
const updated = await webhook.updateRegistrationStatus(r.id);
|
||||
if (updated.status === 'paid') paidIds.push(r.id);
|
||||
}
|
||||
if (paidIds.length === regs.length) await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { status: 'paid' } });
|
||||
if (created[0]) await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: created[0].id }).catch(() => {});
|
||||
|
||||
(async () => {
|
||||
const { sendPaymentEmails } = require('./notifications');
|
||||
for (const p of created) {
|
||||
try { await sendPaymentEmails(p.id); } catch (e) { console.error('[season-pass] payment email:', e?.message || e); }
|
||||
}
|
||||
})();
|
||||
emailTicketsFor(purchase.userId, paidIds);
|
||||
return { status: 'success', purchaseId: purchase.id, payments: created.map(p => ({ id: p.id, amount: p.amount, eventId: p.eventId })) };
|
||||
}
|
||||
|
||||
/** Route guard: a registration from a season pass keeps its lines as bought (no edits, no coupons). */
|
||||
function notOnSeasonPass(req, res, next) {
|
||||
prisma.registration.findUnique({ where: { id: req.params.id }, select: { seasonPassPurchaseId: true } })
|
||||
.then(r => (r?.seasonPassPurchaseId
|
||||
? res.status(409).json({ message: 'This registration is part of a season pass, so its tickets can\'t be changed and codes can\'t be added. Ask the organiser if something needs to change.' })
|
||||
: next()))
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
// ─── Privacy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function exportPurchases(userId, db = prisma) {
|
||||
const rows = await db.seasonPassPurchase.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { quantity: true, total: true, status: true, createdAt: true, pass: { select: { name: true } } } });
|
||||
return rows.map(r => ({ pass: r.pass.name, quantity: r.quantity, total: r.total, status: r.status, at: r.createdAt }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
splitCents, itemShares, allocate, normalPrice, notOnSale, passView, cleanPassInput, loadPass,
|
||||
buyPass, startPassCheckout, recordPassPayment, notOnSeasonPass, exportPurchases, passError, PASS_INCLUDE,
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Event series (1.47.1): separate events grouped on one series page — every year's camp, the
|
||||
* monthly men's breakfast. A series isn't tied to a campus, so it can have events at several.
|
||||
* Each event page links to the next public event in its series, and people can follow a series:
|
||||
* when a new event in it goes live they get one email, sent as News & announcements (so only to
|
||||
* people who opted in to news, with an unsubscribe link).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { slugify } = require('./campuses');
|
||||
|
||||
function seriesError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Events anyone can see: active, not hidden and live. */
|
||||
function publicEventWhere(now = new Date()) {
|
||||
return { isActive: true, isHidden: false, goLiveAt: { lte: now } };
|
||||
}
|
||||
|
||||
/** Name, slug, description and picture from a create/update body (`current` on update). */
|
||||
function cleanSeriesInput(body = {}, current = null) {
|
||||
const data = {};
|
||||
if (body.name !== undefined || !current) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw seriesError('Give the series a name');
|
||||
if (name.length > 120) throw seriesError('The name is too long');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.slug !== undefined || !current) {
|
||||
const slug = slugify(body.slug || data.name || current?.name);
|
||||
if (!slug) throw seriesError('The web address needs letters or numbers');
|
||||
data.slug = slug;
|
||||
}
|
||||
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
|
||||
if (body.picture !== undefined) data.picture = String(body.picture || '').trim() || null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The series to save on an event from a body's `seriesId`: undefined = leave as is, null = none. */
|
||||
async function seriesForEvent(seriesId, db = prisma) {
|
||||
if (seriesId === undefined) return undefined;
|
||||
if (!seriesId) return null;
|
||||
const found = await db.eventSeries.findUnique({ where: { id: String(seriesId) }, select: { id: true } });
|
||||
if (!found) throw seriesError('That series no longer exists');
|
||||
return found.id;
|
||||
}
|
||||
|
||||
const NEXT_SELECT = { id: true, title: true, startDate: true, endDate: true, location: true, redirectUrl: true };
|
||||
|
||||
/**
|
||||
* The next public event in the same series after `event` (starting later, not over yet), or null.
|
||||
*/
|
||||
async function nextInSeries(event, db = prisma, now = new Date()) {
|
||||
if (!event?.seriesId) return null;
|
||||
return db.event.findFirst({
|
||||
where: { ...publicEventWhere(now), seriesId: event.seriesId, id: { not: event.id }, startDate: { gt: event.startDate }, endDate: { gte: now } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: NEXT_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a series' public events into upcoming (not over yet, soonest first) and past (latest
|
||||
* first). Pure, for the series page.
|
||||
*/
|
||||
function splitEvents(events, now = new Date()) {
|
||||
const upcoming = events.filter(e => new Date(e.endDate) >= now).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
||||
const past = events.filter(e => new Date(e.endDate) < now).sort((a, b) => new Date(b.startDate) - new Date(a.startDate));
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
||||
// ─── Following ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function follow(seriesId, userId) {
|
||||
const series = await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } });
|
||||
if (!series) throw seriesError('Series not found', 404);
|
||||
await prisma.seriesFollow.upsert({ where: { seriesId_userId: { seriesId, userId } }, create: { seriesId, userId }, update: {} });
|
||||
}
|
||||
|
||||
async function unfollow(seriesId, userId) {
|
||||
await prisma.seriesFollow.deleteMany({ where: { seriesId, userId } });
|
||||
}
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `series` job: for each event in a series that is now public and hasn't started, email the
|
||||
* series' followers once. The event is claimed first (seriesAnnouncedAt), so a slow run or two
|
||||
* workers never send twice. An event made public after it started is never announced.
|
||||
*/
|
||||
async function runAnnounceJob(now = new Date()) {
|
||||
const due = await prisma.event.findMany({
|
||||
where: { ...publicEventWhere(now), seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } },
|
||||
select: { id: true, title: true, startDate: true, location: true, redirectUrl: true, series: { select: { id: true, name: true, slug: true } } },
|
||||
take: 50,
|
||||
});
|
||||
let announced = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
for (const ev of due) {
|
||||
const claimed = await prisma.event.updateMany({ where: { id: ev.id, seriesAnnouncedAt: null }, data: { seriesAnnouncedAt: now } });
|
||||
if (!claimed.count) continue;
|
||||
announced++;
|
||||
const follows = await prisma.seriesFollow.findMany({
|
||||
where: { seriesId: ev.series.id, user: { isActive: true } },
|
||||
select: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
const { filterRecipients, bulkEmailFooter } = require('./privacy');
|
||||
const { recipients, skipped: notNews } = await filterRecipients(
|
||||
follows.filter(f => f.user.email).map(f => ({ email: f.user.email, name: f.user.name, userId: f.user.id })),
|
||||
{ channel: 'email', kind: 'news' },
|
||||
);
|
||||
skipped += notNews;
|
||||
const { sendMail } = require('./email');
|
||||
for (const r of recipients) {
|
||||
const footer = bulkEmailFooter({ kind: 'news', recipient: r });
|
||||
const msg = announcementEmail({ name: r.name, event: ev, series: ev.series });
|
||||
try {
|
||||
await sendMail({ to: r.email, subject: msg.subject, html: msg.html + footer.html, text: msg.text + footer.text, headers: footer.headers });
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[series] failed to email', r.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { announced, sent, skipped };
|
||||
}
|
||||
|
||||
function announcementEmail({ name, event, series }) {
|
||||
const { emailWrapper, ctaButton } = require('./email');
|
||||
const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const when = new Date(event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const url = `${frontendUrl()}/events/${event.id}`;
|
||||
const seriesUrl = `${frontendUrl()}/series/${series.slug}`;
|
||||
const subject = `New in ${series.name}: ${event.title}`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${esc(event.title)}</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 24px 0">New in ${esc(series.name)}</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">You follow <strong>${esc(series.name)}</strong>, and the next one is out: <strong>${esc(event.title)}</strong> on ${esc(when)}${event.location ? `, ${esc(event.location)}` : ''}.</p>
|
||||
${ctaButton('See the event', url)}
|
||||
<p style="margin:16px 0 0 0;font-size:13px;color:#64748b">To stop hearing about this series, unfollow it on <a href="${seriesUrl}" style="color:#64748b">its page</a>.</p>`;
|
||||
const text = `Hi ${name || 'there'},\n\nYou follow ${series.name}, and the next one is out: ${event.title} on ${when}${event.location ? `, ${event.location}` : ''}.\n\nSee the event: ${url}\n\nTo stop hearing about this series, unfollow it on its page: ${seriesUrl}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader: subject }) };
|
||||
}
|
||||
|
||||
// ─── Year-on-year comparison (1.47.2) ────────────────────────────────────────
|
||||
|
||||
// The same "real income" as the dashboard's top events: everything except the legs that apply an
|
||||
// earlier donation to an order (not new money). Refunds count, as negatives.
|
||||
const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
const pct = (now, before) => (before > 0 ? Math.round(((now - before) / before) * 100) : null);
|
||||
|
||||
/**
|
||||
* Adds `change` to each row against the row before it (rows oldest first): the percentage up or
|
||||
* down for registrations, tickets, income and checked in, null when there's nothing to compare.
|
||||
* Pure, for tests.
|
||||
*/
|
||||
function withChanges(rows) {
|
||||
return rows.map((r, i) => {
|
||||
const prev = rows[i - 1];
|
||||
return {
|
||||
...r,
|
||||
change: prev
|
||||
? { registrations: pct(r.registrations, prev.registrations), tickets: pct(r.tickets, prev.tickets), income: pct(r.income, prev.income), checkedIn: pct(r.checkedIn, prev.checkedIn) }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrations, tickets, income and attendance for each event in a series that the person may
|
||||
* see (`scopeWhere`, from campusScope.eventWhere), oldest first, with the change from the one
|
||||
* before. Checked in: check-ins on a single-date event; on a multi-session event, the average per
|
||||
* session that wasn't cancelled.
|
||||
*/
|
||||
async function compareSeries(seriesId, { scopeWhere = {} } = {}, db = prisma) {
|
||||
const series = await db.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true, name: true, slug: true } });
|
||||
if (!series) throw seriesError('Series not found', 404);
|
||||
const events = await db.event.findMany({
|
||||
where: { AND: [{ seriesId }, scopeWhere] },
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true,
|
||||
campus: { select: { id: true, name: true, slug: true, colour: true } },
|
||||
_count: { select: { sessions: { where: { cancelledAt: null } } } },
|
||||
},
|
||||
});
|
||||
const ids = events.map(e => e.id);
|
||||
const [regs, tickets, income, usages] = ids.length ? await Promise.all([
|
||||
db.registration.groupBy({ by: ['eventId'], where: { eventId: { in: ids }, status: { not: 'cancelled' } }, _count: { _all: true } }),
|
||||
db.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: ids } }, _sum: { quantity: true } }),
|
||||
db.payment.groupBy({ by: ['eventId'], where: { ...EXCLUDE_DONATION_LEGS, eventId: { in: ids } }, _sum: { amount: true } }),
|
||||
// People checked in (a group scan can let several in at once), per event through the ticket.
|
||||
Promise.all(ids.map(async eventId => ({ eventId, n: (await db.ticketUsage.aggregate({ where: { ticket: { eventId } }, _sum: { quantityRedeemed: true } }))._sum.quantityRedeemed || 0 }))),
|
||||
]) : [[], [], [], []];
|
||||
const map = (rows, pick) => new Map(rows.map(r => [r.eventId, pick(r)]));
|
||||
const regBy = map(regs, r => r._count._all);
|
||||
const ticketBy = map(tickets, r => r._sum.quantity || 0);
|
||||
const incomeBy = map(income, r => r._sum.amount || 0);
|
||||
const usageBy = map(usages, r => r.n);
|
||||
|
||||
const rows = events.map(e => {
|
||||
const scans = usageBy.get(e.id) || 0;
|
||||
const sessions = e.scheduleType === 'sessions' ? e._count.sessions : 0;
|
||||
const t = ticketBy.get(e.id) || 0;
|
||||
const checkedIn = sessions > 0 ? Math.round(scans / sessions) : scans;
|
||||
return {
|
||||
eventId: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, campus: e.campus, isActive: e.isActive,
|
||||
sessions,
|
||||
registrations: regBy.get(e.id) || 0,
|
||||
tickets: t,
|
||||
income: Math.round((incomeBy.get(e.id) || 0) * 100) / 100,
|
||||
checkedIn,
|
||||
attendanceRate: t > 0 ? Math.min(100, Math.round((checkedIn / t) * 100)) : null,
|
||||
};
|
||||
});
|
||||
return { series, events: withChanges(rows) };
|
||||
}
|
||||
|
||||
// ─── Privacy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Series someone follows, for "Download my data". */
|
||||
async function exportFollows(userId, db = prisma) {
|
||||
const rows = await db.seriesFollow.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { createdAt: true, series: { select: { name: true } } } });
|
||||
return rows.map(r => ({ series: r.series.name, since: r.createdAt }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
|
||||
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError, withChanges, compareSeries,
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Outbound webhooks (1.48.0): admins add endpoints (Site settings → Webhooks) and choose event
|
||||
* types; what happens here is POSTed to them as JSON, signed with the endpoint's secret.
|
||||
*
|
||||
* - Events are noticed in one place, a Prisma client extension (config/db.js), so every path that
|
||||
* creates a registration, marks it paid, records a payment, checks a ticket in… is covered.
|
||||
* - `notice` only queues (in memory); about a second later `flush` reads the record again — a
|
||||
* transaction that rolled back, or a status that changed back, sends nothing — builds the
|
||||
* payload and stores one WebhookDelivery per endpoint that wants the type, then sends them.
|
||||
* - Sending: `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`
|
||||
* (like Stripe), 10 s timeout, redirects not followed, 2xx = delivered. Failures are retried
|
||||
* after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed (Resend on the delivery log).
|
||||
* - Only https URLs on public addresses (no SSRF into the server's own network), unless
|
||||
* WEBHOOKS_ALLOW_PRIVATE=true (local testing).
|
||||
* - Never sent: kids check-in and volunteer tickets (children's data stays here).
|
||||
* - Deliveries hold personal information, so they're kept 30 days and removed with an account.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns').promises;
|
||||
const net = require('net');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('./encryption');
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{ type: 'registration.created', label: 'Registration made', description: 'Someone registers (online, at the door, by staff or with a season pass).' },
|
||||
{ type: 'registration.paid', label: 'Registration paid', description: 'A registration is paid in full — or made free.' },
|
||||
{ type: 'registration.cancelled', label: 'Registration cancelled', description: 'A registration is cancelled.' },
|
||||
{ type: 'payment.received', label: 'Payment received', description: 'Any payment or donation is recorded (online or by staff).' },
|
||||
{ type: 'payment.refunded', label: 'Payment refunded', description: 'A refund is recorded.' },
|
||||
{ type: 'ticket.checked_in', label: 'Ticket checked in', description: 'A ticket is scanned or ticked present.' },
|
||||
{ type: 'user.created', label: 'Account created', description: 'A new account is made.' },
|
||||
{ type: 'event.published', label: 'Event published', description: 'An event becomes public (made active and not hidden).' },
|
||||
];
|
||||
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
|
||||
|
||||
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
|
||||
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
|
||||
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
|
||||
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
|
||||
const exampleRegistration = (status, amountPaid) => ({
|
||||
registration: {
|
||||
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
|
||||
seasonPassPurchaseId: null,
|
||||
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
|
||||
},
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const examplePayment = (amount) => ({
|
||||
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const EXAMPLES = {
|
||||
'registration.created': exampleRegistration('pending', 0),
|
||||
'registration.paid': exampleRegistration('paid', 1200),
|
||||
'registration.cancelled': exampleRegistration('cancelled', 0),
|
||||
'payment.received': examplePayment(1200),
|
||||
// Refunds are recorded as negative payments.
|
||||
'payment.refunded': examplePayment(-600),
|
||||
'ticket.checked_in': {
|
||||
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
|
||||
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
},
|
||||
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
|
||||
'event.published': {
|
||||
event: {
|
||||
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
|
||||
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
|
||||
campus: { name: 'North', slug: 'north' }, series: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
|
||||
|
||||
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
|
||||
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
|
||||
const KEEP_DAYS = 30;
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
function webhookError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const allowPrivate = () => String(process.env.WEBHOOKS_ALLOW_PRIVATE || '').toLowerCase() === 'true';
|
||||
|
||||
// ─── Endpoint addresses ──────────────────────────────────────────────────────
|
||||
|
||||
/** Whether an IP address is private, loopback, link-local or otherwise not on the internet. */
|
||||
function isPrivateAddress(ip) {
|
||||
if (net.isIPv4(ip)) {
|
||||
const [a, b] = ip.split('.').map(Number);
|
||||
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
}
|
||||
if (net.isIPv6(ip)) {
|
||||
const v = ip.toLowerCase();
|
||||
if (v === '::1' || v === '::') return true;
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v);
|
||||
if (mapped) return isPrivateAddress(mapped[1]);
|
||||
return /^f[cd]/.test(v) || /^fe[89ab]/.test(v);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A cleaned endpoint URL, or an error: https (http only for local testing), no user:password. */
|
||||
function cleanUrl(raw) {
|
||||
let u;
|
||||
try { u = new URL(String(raw || '').trim()); } catch { throw webhookError('Enter the full address, starting with https://'); }
|
||||
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && allowPrivate())) throw webhookError('The address must start with https://');
|
||||
if (u.username || u.password) throw webhookError('Leave the user name and password out of the address');
|
||||
if (!allowPrivate() && (u.hostname === 'localhost' || (net.isIP(u.hostname) && isPrivateAddress(u.hostname)))) {
|
||||
throw webhookError('That address is on a private network — webhooks only go to public addresses');
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** Refuses a host that resolves to a private address (checked at every send: DNS can change). */
|
||||
async function assertPublicHost(hostname) {
|
||||
if (allowPrivate()) return;
|
||||
const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
|
||||
if (!addresses.length || addresses.some(a => isPrivateAddress(a.address))) {
|
||||
throw webhookError(`${hostname} points to a private network address`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Signing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const newSecret = () => `whsec_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
|
||||
/** The signature header for a body: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. */
|
||||
function signatureHeader(secret, body, at = Date.now()) {
|
||||
const t = Math.floor(at / 1000);
|
||||
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
||||
return `t=${t},v1=${v1}`;
|
||||
}
|
||||
|
||||
// ─── Endpoints (cached for `notice`) ─────────────────────────────────────────
|
||||
|
||||
let endpointCache = null;
|
||||
let endpointCacheAt = 0;
|
||||
async function activeEndpoints() {
|
||||
if (!endpointCache || Date.now() - endpointCacheAt > 60e3) {
|
||||
endpointCache = await prisma.webhookEndpoint.findMany({ where: { isActive: true }, select: { id: true, events: true } });
|
||||
endpointCacheAt = Date.now();
|
||||
}
|
||||
return endpointCache;
|
||||
}
|
||||
function forgetEndpoints() { endpointCache = null; }
|
||||
|
||||
// ─── Noticing events ─────────────────────────────────────────────────────────
|
||||
|
||||
const queue = [];
|
||||
let flushTimer = null;
|
||||
const FLUSH_DELAY_MS = 1500;
|
||||
|
||||
/**
|
||||
* Something happened (from the Prisma extension). Queued and checked again shortly; never throws.
|
||||
* `ref` is the record's id.
|
||||
*/
|
||||
function notice(type, ref) {
|
||||
if (!TYPES.has(type) || !ref) return;
|
||||
if (String(process.env.WEBHOOKS_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
queue.push({ type, ref, at: new Date() });
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => { flushTimer = null; flush().catch(e => console.warn('[webhooks] flush:', e?.message || e)); }, FLUSH_DELAY_MS);
|
||||
flushTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds and stores the deliveries for what was noticed, then sends them. */
|
||||
async function flush() {
|
||||
const items = queue.splice(0, queue.length);
|
||||
if (!items.length) return [];
|
||||
const endpoints = await activeEndpoints();
|
||||
const made = [];
|
||||
for (const item of items) {
|
||||
const targets = endpoints.filter(e => e.events.includes(item.type));
|
||||
if (!targets.length) continue;
|
||||
let built;
|
||||
try { built = await buildPayload(item.type, item.ref); } catch (e) { console.warn(`[webhooks] ${item.type} ${item.ref}:`, e?.message || e); continue; }
|
||||
if (!built) continue;
|
||||
const eventId = `evt_${crypto.randomUUID()}`;
|
||||
const payload = { id: eventId, type: item.type, createdAt: item.at.toISOString(), data: built.data };
|
||||
for (const ep of targets) {
|
||||
made.push(await prisma.webhookDelivery.create({ data: { endpointId: ep.id, eventId, type: item.type, payload, userId: built.userId || null } }));
|
||||
}
|
||||
}
|
||||
for (const d of made) await send(d.id).catch(e => console.warn('[webhooks] send:', e?.message || e));
|
||||
return made;
|
||||
}
|
||||
|
||||
// ─── Payloads ────────────────────────────────────────────────────────────────
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const person = u => (u ? { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber || null } : null);
|
||||
const eventRef = e => (e ? { id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate } : null);
|
||||
const reservedIds = e => new Set([e?.kidsOptionId, e?.volunteerOptionId].filter(Boolean));
|
||||
const EVENT_SELECT = { id: true, title: true, startDate: true, endDate: true, kidsOptionId: true, volunteerOptionId: true };
|
||||
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true };
|
||||
|
||||
async function registrationData(id, status) {
|
||||
const r = await prisma.registration.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, payments: true,
|
||||
registrationOptions: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { name: true } }, tranches: true } },
|
||||
},
|
||||
});
|
||||
if (!r || (status && r.status !== status)) return null;
|
||||
// Kids check-in and volunteer places are internal (and can be about children): never sent.
|
||||
const reserved = reservedIds(r.event);
|
||||
if (r.registrationOptions.length && r.registrationOptions.every(o => reserved.has(o.eventOptionId))) return null;
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const totalDue = Math.round(computeRegistrationTotalDue(r, new Date()) * 100) / 100;
|
||||
const paid = Math.round(r.payments.reduce((s, p) => s + p.amount, 0) * 100) / 100;
|
||||
return {
|
||||
userId: r.userId,
|
||||
data: {
|
||||
registration: {
|
||||
id: r.id, status: r.status, createdAt: r.createdAt, totalDue, amountPaid: paid,
|
||||
seasonPassPurchaseId: r.seasonPassPurchaseId || null,
|
||||
items: r.registrationOptions.filter(o => !reserved.has(o.eventOptionId)).map(o => ({ ticket: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, unitPrice: o.priceSnapshot })),
|
||||
},
|
||||
event: eventRef(r.event),
|
||||
person: person(r.user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPayload(type, ref) {
|
||||
switch (type) {
|
||||
case 'registration.created': return registrationData(ref);
|
||||
case 'registration.paid': return registrationData(ref, 'paid');
|
||||
case 'registration.cancelled': return registrationData(ref, 'cancelled');
|
||||
case 'payment.received':
|
||||
case 'payment.refunded': {
|
||||
const p = await prisma.payment.findUnique({ where: { id: ref }, include: { user: { select: USER_SELECT }, event: { select: EVENT_SELECT } } });
|
||||
if (!p) return null;
|
||||
return {
|
||||
userId: p.userId,
|
||||
data: {
|
||||
payment: { id: p.id, amount: p.amount, method: p.method, provider: p.provider || null, isDonation: p.isDonation, registrationId: p.registrationId, createdAt: p.createdAt },
|
||||
event: eventRef(p.event),
|
||||
person: person(p.user),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'ticket.checked_in': {
|
||||
const u = await prisma.ticketUsage.findUnique({
|
||||
where: { id: ref },
|
||||
include: {
|
||||
session: { select: { id: true, label: true, startsAt: true } },
|
||||
ticket: { include: { event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, holder: { select: USER_SELECT }, attendee: { select: { name: true } }, registrationOption: { select: { eventOptionId: true } } } },
|
||||
},
|
||||
});
|
||||
if (!u) return null;
|
||||
if (reservedIds(u.ticket.event).has(u.ticket.registrationOption?.eventOptionId)) return null;
|
||||
// A transferred ticket belongs to its holder now.
|
||||
const who = u.ticket.holder || u.ticket.user;
|
||||
return {
|
||||
userId: who?.id || u.ticket.userId,
|
||||
data: {
|
||||
checkIn: { id: u.id, scannedAt: u.scannedAt, people: u.quantityRedeemed, manual: u.manual, offline: u.offline, session: u.session || null },
|
||||
ticket: { id: u.ticket.id, registrationOptionId: u.ticket.registrationOptionId, holderName: u.ticket.attendee?.name || who?.name || null },
|
||||
event: eventRef(u.ticket.event),
|
||||
person: person(who),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'user.created': {
|
||||
const user = await prisma.user.findUnique({ where: { id: ref }, select: { ...USER_SELECT, role: true, createdAt: true } });
|
||||
if (!user) return null;
|
||||
return { userId: user.id, data: { person: { ...person(user), role: user.role, createdAt: user.createdAt } } };
|
||||
}
|
||||
case 'event.published': {
|
||||
const e = await prisma.event.findUnique({
|
||||
where: { id: ref },
|
||||
select: { ...EVENT_SELECT, description: true, location: true, price: true, isActive: true, isHidden: true, goLiveAt: true, campus: { select: { name: true, slug: true } }, series: { select: { name: true, slug: true } } },
|
||||
});
|
||||
if (!e || !e.isActive || e.isHidden) return null;
|
||||
return {
|
||||
userId: null,
|
||||
data: {
|
||||
event: {
|
||||
id: e.id, title: e.title, description: e.description, startDate: e.startDate, endDate: e.endDate, location: e.location,
|
||||
price: e.price, goLiveAt: e.goLiveAt, url: `${frontendUrl()}/events/${e.id}`, campus: e.campus || null, series: e.series || null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sending ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The wait before the next try after `attempts` failed tries, or null when it's given up. Pure. */
|
||||
function retryDelay(attempts) {
|
||||
return attempts < MAX_ATTEMPTS ? RETRY_AFTER_MS[attempts - 1] : null;
|
||||
}
|
||||
|
||||
/** POSTs one delivery (claimed first, so two workers never send it twice). */
|
||||
async function send(deliveryId) {
|
||||
const claimed = await prisma.webhookDelivery.updateMany({ where: { id: deliveryId, status: { in: ['pending', 'retrying'] } }, data: { status: 'sending', nextAttemptAt: new Date() } });
|
||||
if (!claimed.count) return null;
|
||||
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, include: { endpoint: true } });
|
||||
const attempts = d.attempts + 1;
|
||||
const body = JSON.stringify(d.payload);
|
||||
let statusCode = null;
|
||||
let error = null;
|
||||
try {
|
||||
if (!d.endpoint.isActive) throw webhookError('The endpoint is switched off');
|
||||
const url = new URL(cleanUrl(d.endpoint.url));
|
||||
await assertPublicHost(url.hostname);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'CrossEvents-Webhooks/1',
|
||||
'X-CrossEvents-Event': d.type,
|
||||
'X-CrossEvents-Delivery': d.id,
|
||||
'X-CrossEvents-Signature': signatureHeader(decrypt(d.endpoint.secret), body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
statusCode = res.status;
|
||||
if (res.status < 200 || res.status >= 300) error = `Answered ${res.status}${res.status >= 300 && res.status < 400 ? ' (redirects are not followed)' : ''}`;
|
||||
} catch (e) {
|
||||
error = e?.name === 'TimeoutError' ? `No answer within ${TIMEOUT_MS / 1000} seconds` : (e?.message || String(e));
|
||||
}
|
||||
const now = new Date();
|
||||
const ok = !error;
|
||||
const wait = ok ? null : retryDelay(attempts);
|
||||
const updated = await prisma.webhookDelivery.update({
|
||||
where: { id: d.id },
|
||||
data: {
|
||||
attempts, lastStatusCode: statusCode, lastError: error ? String(error).slice(0, 500) : null,
|
||||
status: ok ? 'succeeded' : wait ? 'retrying' : 'failed',
|
||||
deliveredAt: ok ? now : null,
|
||||
nextAttemptAt: wait ? new Date(now.getTime() + wait) : now,
|
||||
},
|
||||
});
|
||||
await prisma.webhookEndpoint.update({ where: { id: d.endpointId }, data: ok ? { lastSuccessAt: now } : { lastFailureAt: now } }).catch(() => {});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** The `webhooks` job: sends what's due, picks up sends cut off by a restart, prunes old deliveries. */
|
||||
async function runJob(now = new Date()) {
|
||||
// A send is claimed with nextAttemptAt = now; one still "sending" 5 minutes later was cut off.
|
||||
await prisma.webhookDelivery.updateMany({ where: { status: 'sending', nextAttemptAt: { lt: new Date(now.getTime() - 5 * 60e3) } }, data: { status: 'retrying' } });
|
||||
const due = await prisma.webhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, orderBy: { nextAttemptAt: 'asc' }, take: 100, select: { id: true } });
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of due) {
|
||||
const r = await send(id).catch(() => null);
|
||||
if (r?.status === 'succeeded') sent++;
|
||||
else if (r) failed++;
|
||||
}
|
||||
const pruned = await prisma.webhookDelivery.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_DAYS * 864e5) } } });
|
||||
return { due: due.length, sent, failed, pruned: pruned.count };
|
||||
}
|
||||
|
||||
/** Sends a delivery again now (a fresh try, whatever happened before). */
|
||||
async function resend(deliveryId) {
|
||||
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, select: { id: true } });
|
||||
if (!d) throw webhookError('Delivery not found', 404);
|
||||
await prisma.webhookDelivery.update({ where: { id: d.id }, data: { status: 'pending', attempts: 0, nextAttemptAt: new Date() } });
|
||||
return send(d.id);
|
||||
}
|
||||
|
||||
/** A "webhook.test" delivery to one endpoint, sent at once. */
|
||||
async function sendTest(endpointId) {
|
||||
const eventId = `evt_${crypto.randomUUID()}`;
|
||||
const payload = { id: eventId, type: 'webhook.test', createdAt: new Date().toISOString(), data: { message: 'A test from Cross Events. If you can read this, the webhook works.' } };
|
||||
const d = await prisma.webhookDelivery.create({ data: { endpointId, eventId, type: 'webhook.test', payload } });
|
||||
return send(d.id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVENT_TYPES, TYPES, MAX_ATTEMPTS, KEEP_DAYS,
|
||||
isPrivateAddress, cleanUrl, assertPublicHost, newSecret, signatureHeader, encrypt,
|
||||
notice, flush, buildPayload, retryDelay, send, runJob, resend, sendTest, forgetEndpoints, webhookError,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* /api/v2 errors all look the same:
|
||||
* { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } }
|
||||
* `code` is stable (clients switch on it), `message` is for people, `fields` only on validation.
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'VALIDATION_FAILED'
|
||||
| 'UNAUTHENTICATED'
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'CONFLICT'
|
||||
| 'RATE_LIMITED'
|
||||
| 'READ_ONLY'
|
||||
| 'INTERNAL';
|
||||
|
||||
export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
VALIDATION_FAILED: 400,
|
||||
UNAUTHENTICATED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
RATE_LIMITED: 429,
|
||||
READ_ONLY: 423,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly status: number;
|
||||
readonly fields?: Record<string, string>;
|
||||
|
||||
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = ERROR_STATUS[code];
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`);
|
||||
|
||||
/** "limit" or "items.0.quantity" → the first problem with it. */
|
||||
export function zodFields(err: ZodError): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const issue of err.issues) {
|
||||
const key = issue.path.length ? issue.path.join('.') : '_';
|
||||
if (!fields[key]) fields[key] = issue.message;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
|
||||
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
|
||||
|
||||
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
|
||||
|
||||
/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */
|
||||
export function toApiError(err: unknown, responseStatus: number): ApiError {
|
||||
if (err instanceof ApiError) return err;
|
||||
if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err));
|
||||
const e = (err || {}) as LegacyError;
|
||||
if (typeof e.code === 'string' && PRISMA_CODE[e.code]) {
|
||||
return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.');
|
||||
}
|
||||
// The shared v1 middleware (protect, staff…) sets res.status() and passes an Error;
|
||||
// utils throw errors with statusCode. Those messages are written for people already.
|
||||
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
|
||||
const code = STATUS_CODE[status];
|
||||
if (code) return new ApiError(code, e.message || 'Request failed.');
|
||||
return new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const apiErr = toApiError(err, res.statusCode);
|
||||
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
|
||||
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } });
|
||||
};
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
|
||||
@@ -0,0 +1,93 @@
|
||||
import express, { Router } from 'express';
|
||||
import path from 'path';
|
||||
import { mount, routes } from './route';
|
||||
import { buildOpenApi } from './openapi';
|
||||
import { errorHandler, notFoundHandler } from './errors';
|
||||
import auth from '../middleware/authMiddleware';
|
||||
// Resources: importing one registers its routes.
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
/** /api/v2 — see route.ts for how endpoints are declared, openapi.ts for the docs. */
|
||||
export function v2Router(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// The API description is for the people building on it (admins), like /docs.
|
||||
router.get('/openapi.json', auth.protect, auth.admin, (_req, res) => {
|
||||
res.json(buildOpenApi({ version: pkg.version }));
|
||||
});
|
||||
|
||||
mount(router, routes);
|
||||
router.use(notFoundHandler);
|
||||
router.use(errorHandler);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* /docs/v2: Swagger UI (served from swagger-ui-dist, nothing from a CDN). The page itself is
|
||||
* public; it asks for an admin token and uses it to load openapi.json and to try requests.
|
||||
*/
|
||||
export function v2DocsRouter(): Router {
|
||||
const router = express.Router();
|
||||
const assets = path.dirname(require.resolve('swagger-ui-dist/package.json'));
|
||||
router.use('/assets', express.static(assets, { index: false, maxAge: '1d' }));
|
||||
router.get('/', (_req, res) => {
|
||||
res.type('html').send(DOCS_HTML);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
const DOCS_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cross Events API v2</title>
|
||||
<link rel="stylesheet" href="/docs/v2/assets/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
#login { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
|
||||
#login input { width: 100%; padding: .5rem; font: inherit; border: 1px solid #9ca3af; border-radius: 6px; box-sizing: border-box; }
|
||||
#login button { margin-top: .75rem; padding: .5rem 1rem; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
|
||||
#login .err { color: #b91c1c; margin-top: .5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="login" hidden>
|
||||
<h1>Cross Events API v2</h1>
|
||||
<p>The API reference is for admins. Paste your admin token (after signing in, it's under <code>hope_events_token</code> in the browser's local storage).</p>
|
||||
<label for="token">Admin token</label>
|
||||
<input id="token" autocomplete="off">
|
||||
<button id="go" type="button">Show the API</button>
|
||||
<p class="err" id="err" role="alert"></p>
|
||||
</main>
|
||||
<div id="ui"></div>
|
||||
<script src="/docs/v2/assets/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var token = params.get('token') || sessionStorage.getItem('v2docs_token') || localStorage.getItem('hope_events_token') || '';
|
||||
function show(t) {
|
||||
fetch('/api/v2/openapi.json', { headers: { Authorization: 'Bearer ' + t } }).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (b) { throw new Error((b.error && b.error.message) || r.statusText); });
|
||||
return r.json();
|
||||
}).then(function (spec) {
|
||||
sessionStorage.setItem('v2docs_token', t);
|
||||
if (params.has('token')) history.replaceState(null, '', location.pathname);
|
||||
document.getElementById('login').hidden = true;
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
document.getElementById('err').textContent = t ? 'That token did not work: ' + e.message : '';
|
||||
});
|
||||
}
|
||||
document.getElementById('go').addEventListener('click', function () { show(document.getElementById('token').value.trim()); });
|
||||
show(token);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Typed doors into the JavaScript side of the backend. TypeScript infers loose types from the
|
||||
* .js files (the Prisma client is built by a function, helpers have defaulted options), so v2
|
||||
* code imports them from here instead. When a module is converted to TypeScript, its line here
|
||||
* goes and callers import it directly.
|
||||
*/
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
// The client in config/db.js only adds query hooks (webhooks), so its models are PrismaClient's.
|
||||
export const prisma = require('../config/db') as PrismaClient;
|
||||
|
||||
type CampusFilter = '' | 'all' | string;
|
||||
const campuses = require('../utils/campuses') as {
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
resolveCampusFilter(value: string | undefined): Promise<CampusFilter>;
|
||||
campusWhere(filter: CampusFilter, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
|
||||
};
|
||||
export const { campusesEnabled, resolveCampusFilter, campusWhere } = campuses;
|
||||
|
||||
export const { publicEventWhere } = require('../utils/series') as {
|
||||
publicEventWhere(now?: Date): { isActive: true; isHidden: false; goLiveAt: { lte: Date } };
|
||||
};
|
||||
|
||||
type OptionLike = { id: string; price: number; earlyBirdTiers?: unknown[] };
|
||||
export const { publicOptions } = require('../utils/volunteers') as {
|
||||
publicOptions<O extends { id: string }>(event: { eventOptions?: O[]; kidsOptionId?: string | null; volunteerOptionId?: string | null }): O[];
|
||||
};
|
||||
|
||||
export const { getEffectiveUnitPrice, computeRegistrationTotalDue } = require('../utils/pricing') as {
|
||||
getEffectiveUnitPrice(option: OptionLike, referenceTime: Date | null, atTime: Date): number;
|
||||
computeRegistrationTotalDue(registration: object, atTime: Date): number;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod';
|
||||
import { routes as allRoutes, type AnyRoute, type Access } from './route';
|
||||
import { ERROR_STATUS } from './errors';
|
||||
|
||||
/**
|
||||
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
|
||||
* always what the code does. Served at /api/v2/openapi.json, shown at /docs/v2.
|
||||
*/
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
const toSchema = (schema: z.ZodType, io: 'input' | 'output'): JsonSchema => {
|
||||
const { $schema: _drop, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const ACCESS_TEXT: Record<Access, string> = {
|
||||
public: 'Anyone.',
|
||||
optional: 'Anyone; signed-in staff may see more.',
|
||||
user: 'Any signed-in person.',
|
||||
staff: 'Staff, supervisors and admins.',
|
||||
supervisor: 'Supervisors and admins.',
|
||||
admin: 'Admins only.',
|
||||
};
|
||||
|
||||
const errorSchema = {
|
||||
type: 'object',
|
||||
required: ['error'],
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
required: ['code', 'message'],
|
||||
properties: {
|
||||
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
|
||||
message: { type: 'string' },
|
||||
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const errorResponse = (description: string) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } });
|
||||
|
||||
function parameters(def: AnyRoute) {
|
||||
const out: JsonSchema[] = [];
|
||||
const add = (schema: z.ZodType | undefined, where: 'path' | 'query') => {
|
||||
if (!schema) return;
|
||||
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
|
||||
for (const [name, prop] of Object.entries(json.properties || {})) {
|
||||
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
|
||||
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
|
||||
}
|
||||
};
|
||||
add(def.params, 'path');
|
||||
add(def.query, 'query');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
|
||||
const list = opts.list ?? allRoutes;
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const def of list) {
|
||||
const path = def.path.replace(/:(\w+)/g, '{$1}');
|
||||
const secured = def.access !== 'public' && def.access !== 'optional';
|
||||
paths[path] ||= {};
|
||||
paths[path][def.method] = {
|
||||
tags: [def.tag],
|
||||
summary: def.summary,
|
||||
description: [def.description, `**Who:** ${ACCESS_TEXT[def.access]}`].filter(Boolean).join('\n\n'),
|
||||
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
|
||||
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
|
||||
parameters: parameters(def),
|
||||
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
|
||||
responses: {
|
||||
[String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
|
||||
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
|
||||
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
|
||||
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
|
||||
...(def.params ? { 404: errorResponse('Not found (NOT_FOUND)') } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Cross Events API',
|
||||
version: opts.version,
|
||||
description: [
|
||||
'Version 2 of the Cross Events API. Every response is JSON:',
|
||||
'- a single item is `{ "data": { … } }`;',
|
||||
'- a list is `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, paged with `?page=` and `?limit=` (at most 100);',
|
||||
'- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.',
|
||||
'',
|
||||
'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer <token>`.',
|
||||
'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: opts.serverUrl ?? '/api/v2' }],
|
||||
tags: Array.from(new Set(list.map(r => r.tag))).map(name => ({ name })),
|
||||
paths,
|
||||
components: {
|
||||
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
|
||||
schemas: { Error: errorSchema },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** `?page=2&limit=25` on every v2 list (limit 1–100, default 25). */
|
||||
export const pageQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1).describe('Page number, from 1'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Items per page (at most 100)'),
|
||||
});
|
||||
export type PageQuery = z.infer<typeof pageQuery>;
|
||||
|
||||
export const pageMeta = z.object({
|
||||
page: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
total: z.number().int().describe('Items across all pages'),
|
||||
pages: z.number().int().describe('Number of pages (at least 1)'),
|
||||
});
|
||||
|
||||
/** The response shape of a v2 list: `{ data: [...], meta: { page, limit, total, pages } }`. */
|
||||
export const paged = <T extends z.ZodType>(item: T) => z.object({ data: z.array(item), meta: pageMeta });
|
||||
|
||||
/** The response shape of a single v2 resource: `{ data: {...} }`. */
|
||||
export const single = <T extends z.ZodType>(item: T) => z.object({ data: item });
|
||||
|
||||
/** Prisma's skip/take for a page. */
|
||||
export const skipTake = ({ page, limit }: PageQuery) => ({ skip: (page - 1) * limit, take: limit });
|
||||
|
||||
export function pageOf<T>(data: T[], total: number, { page, limit }: PageQuery) {
|
||||
return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { notFound } from '../errors';
|
||||
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
|
||||
import { absoluteUrl, campusRef, id, idParams, iso, isoDate, seriesRef } from '../schemas';
|
||||
import { prisma, campusesEnabled, campusWhere, resolveCampusFilter, publicEventWhere, publicOptions, getEffectiveUnitPrice } from '../legacy';
|
||||
|
||||
const eventSummary = z.object({
|
||||
id,
|
||||
title: z.string(),
|
||||
startDate: isoDate,
|
||||
endDate: isoDate,
|
||||
location: z.string().nullable(),
|
||||
price: z.number().describe('The headline price in rand (0 = free)'),
|
||||
pictureUrl: z.string().nullable(),
|
||||
requiresRegistration: z.boolean(),
|
||||
scheduleType: z.enum(['single', 'sessions']),
|
||||
campus: campusRef.nullable().describe('null = for all campuses (or campuses are off)'),
|
||||
series: seriesRef.nullable(),
|
||||
});
|
||||
|
||||
const ticketOption = z.object({
|
||||
id,
|
||||
name: z.string(),
|
||||
price: z.number().describe('The normal price in rand'),
|
||||
currentPrice: z.number().describe('What it costs now (an early-bird price while one runs)'),
|
||||
isMainTicket: z.boolean(),
|
||||
sessionMode: z.enum(['all', 'pick']).describe('"pick" = the buyer chooses sessions'),
|
||||
});
|
||||
|
||||
const session = z.object({
|
||||
id,
|
||||
label: z.string().nullable(),
|
||||
startsAt: isoDate,
|
||||
endsAt: isoDate,
|
||||
location: z.string().nullable(),
|
||||
cancelled: z.boolean(),
|
||||
});
|
||||
|
||||
const eventDetail = eventSummary.extend({
|
||||
description: z.string().nullable(),
|
||||
registrationDeadline: isoDate.nullable(),
|
||||
ticketOptions: z.array(ticketOption),
|
||||
sessions: z.array(session).describe('Multi-day events only; empty otherwise'),
|
||||
});
|
||||
|
||||
const SUMMARY_SELECT = {
|
||||
id: true, title: true, startDate: true, endDate: true, location: true, price: true, picture: true,
|
||||
requiresRegistration: true, scheduleType: true,
|
||||
campus: { select: { id: true, name: true, slug: true } },
|
||||
series: { select: { id: true, name: true, slug: true } },
|
||||
} as const;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; title: string; startDate: Date; endDate: Date; location: string | null; price: number; picture: string | null;
|
||||
requiresRegistration: boolean; scheduleType: 'single' | 'sessions';
|
||||
campus: { id: string; name: string; slug: string } | null; series: { id: string; name: string; slug: string } | null;
|
||||
};
|
||||
|
||||
const toSummary = (req: Parameters<typeof absoluteUrl>[0], e: SummaryRow): z.input<typeof eventSummary> => ({
|
||||
id: e.id, title: e.title, startDate: iso(e.startDate), endDate: iso(e.endDate), location: e.location ?? null,
|
||||
price: e.price, pictureUrl: absoluteUrl(req, e.picture), requiresRegistration: e.requiresRegistration,
|
||||
scheduleType: e.scheduleType, campus: e.campus ?? null, series: e.series ?? null,
|
||||
});
|
||||
|
||||
export const listEvents = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events',
|
||||
tag: 'Events',
|
||||
summary: 'Upcoming public events',
|
||||
description: 'Events anyone can see (active, not hidden, live) that have not ended, soonest first.',
|
||||
access: 'public',
|
||||
query: pageQuery.extend({
|
||||
q: z.string().trim().max(100).optional().describe('Search the title'),
|
||||
campus: z.string().max(64).optional().describe("A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only"),
|
||||
series: z.string().max(120).optional().describe('A series slug'),
|
||||
}),
|
||||
response: paged(eventSummary),
|
||||
async handler({ query, req }) {
|
||||
const now = new Date();
|
||||
const enabled = await campusesEnabled();
|
||||
const and: object[] = [publicEventWhere(now), { endDate: { gte: now } }];
|
||||
if (query.campus) and.push(campusWhere(await resolveCampusFilter(query.campus), { enabled, withAllCampuses: true }));
|
||||
if (query.series) and.push({ series: { slug: query.series } });
|
||||
if (query.q) and.push({ title: { contains: query.q, mode: 'insensitive' } });
|
||||
const where = { AND: and };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.event.findMany({ where, select: SUMMARY_SELECT, orderBy: [{ startDate: 'asc' }, { id: 'asc' }], ...skipTake(query) }),
|
||||
prisma.event.count({ where }),
|
||||
]);
|
||||
return pageOf((rows as SummaryRow[]).map(e => toSummary(req, e)), total, query);
|
||||
},
|
||||
});
|
||||
|
||||
export const getEvent = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id',
|
||||
tag: 'Events',
|
||||
summary: 'One public event',
|
||||
description: 'With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).',
|
||||
access: 'public',
|
||||
params: idParams,
|
||||
response: single(eventDetail),
|
||||
async handler({ params, req }) {
|
||||
const now = new Date();
|
||||
const { isHidden: _hiddenOk, ...visible } = publicEventWhere(now);
|
||||
const e = await prisma.event.findFirst({
|
||||
where: { id: params.id, ...visible },
|
||||
select: {
|
||||
...SUMMARY_SELECT, description: true, registrationDeadline: true, kidsOptionId: true, volunteerOptionId: true,
|
||||
eventOptions: { include: { earlyBirdTiers: true }, orderBy: [{ isMainTicket: 'desc' }, { name: 'asc' }] },
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!e) throw notFound('That event');
|
||||
const options = publicOptions(e);
|
||||
return {
|
||||
data: {
|
||||
...toSummary(req, e as unknown as SummaryRow),
|
||||
description: e.description ?? null,
|
||||
registrationDeadline: iso(e.registrationDeadline),
|
||||
ticketOptions: options.map(o => ({
|
||||
id: o.id, name: o.name, price: o.price, currentPrice: getEffectiveUnitPrice(o, null, now),
|
||||
isMainTicket: o.isMainTicket, sessionMode: o.sessionMode,
|
||||
})),
|
||||
sessions: e.sessions.map(s => ({ id: s.id, label: s.label, startsAt: iso(s.startsAt), endsAt: iso(s.endsAt), location: s.location, cancelled: !!s.cancelledAt })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
|
||||
import { id, iso, isoDate } from '../schemas';
|
||||
import { prisma } from '../legacy';
|
||||
import { registrationSummary, SUMMARY_INCLUDE, toSummary } from './registrations';
|
||||
|
||||
const me = z.object({
|
||||
id,
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
createdAt: isoDate,
|
||||
});
|
||||
|
||||
export const getMe = defineRoute({
|
||||
method: 'get',
|
||||
path: '/me',
|
||||
tag: 'Me',
|
||||
summary: 'The signed-in person',
|
||||
access: 'user',
|
||||
response: single(me),
|
||||
async handler({ user }) {
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user!.id },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, createdAt: true },
|
||||
});
|
||||
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
|
||||
return { data: { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled, createdAt: iso(u.createdAt) } };
|
||||
},
|
||||
});
|
||||
|
||||
export const listMyRegistrations = defineRoute({
|
||||
method: 'get',
|
||||
path: '/me/registrations',
|
||||
tag: 'Me',
|
||||
summary: 'My registrations',
|
||||
description: 'Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.',
|
||||
access: 'user',
|
||||
query: pageQuery.extend({
|
||||
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'),
|
||||
}),
|
||||
response: paged(registrationSummary),
|
||||
async handler({ query, user }) {
|
||||
const now = new Date();
|
||||
const where = query.past
|
||||
? { userId: user!.id }
|
||||
: { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.registration.findMany({ where, include: SUMMARY_INCLUDE, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], ...skipTake(query) }),
|
||||
prisma.registration.count({ where }),
|
||||
]);
|
||||
return pageOf(rows.map(r => toSummary(r, now)), total, query);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { ApiError, notFound } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { eventRef, id, idParams, iso, isoDate } from '../schemas';
|
||||
import { prisma, computeRegistrationTotalDue } from '../legacy';
|
||||
import { createRegistration, cancelRegistration } from '../../services/registrations';
|
||||
import { startRegistrationCheckout } from '../../services/payments';
|
||||
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration as v2 shows it to its owner. Also used by GET /me/registrations. */
|
||||
export const registrationSummary = z.object({
|
||||
id,
|
||||
status: z.enum(STATUSES),
|
||||
createdAt: isoDate,
|
||||
event: eventRef,
|
||||
items: z.array(z.object({
|
||||
ticket: z.string(),
|
||||
variant: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number(),
|
||||
})),
|
||||
totalDue: z.number().describe('In rand, after discounts'),
|
||||
amountPaid: z.number().describe('In rand (refunds subtracted)'),
|
||||
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
|
||||
});
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export const SUMMARY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, endDate: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
} as const;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; status: (typeof STATUSES)[number]; createdAt: Date;
|
||||
event: { id: string; title: string; startDate: Date; endDate: Date };
|
||||
registrationOptions: { quantity: number; priceSnapshot: number | null; eventOption: { name: string; price: number } | null; variant: { name: string } | null }[];
|
||||
payments: { amount: number }[];
|
||||
};
|
||||
|
||||
export function toSummary(r: SummaryRow, now = new Date()): z.input<typeof registrationSummary> {
|
||||
const totalDue = r2(computeRegistrationTotalDue(r, now));
|
||||
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
|
||||
return {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
createdAt: iso(r.createdAt),
|
||||
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
|
||||
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
|
||||
totalDue,
|
||||
amountPaid,
|
||||
balance: r2(totalDue - amountPaid),
|
||||
};
|
||||
}
|
||||
|
||||
/** The owner's registration (admins may see anyone's); others get NOT_FOUND, not a hint it exists. */
|
||||
async function ownRegistration(registrationId: string, user: ApiUser) {
|
||||
const r = await prisma.registration.findUnique({ where: { id: registrationId }, include: SUMMARY_INCLUDE });
|
||||
if (!r || (r.userId !== user.id && user.role !== 'admin')) throw notFound('That registration');
|
||||
return r;
|
||||
}
|
||||
|
||||
const attendee = z.object({
|
||||
eventOptionId: id,
|
||||
variantId: id.nullish(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.string().trim().max(200).nullish(),
|
||||
phone: z.string().trim().max(40).nullish(),
|
||||
dateOfBirth: z.string().max(40).nullish().describe('YYYY-MM-DD (youth events)'),
|
||||
gender: z.string().max(20).nullish(),
|
||||
parentName: z.string().max(200).nullish(),
|
||||
parentEmail: z.string().max(200).nullish(),
|
||||
parentPhone: z.string().max(40).nullish(),
|
||||
});
|
||||
|
||||
export const register = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations',
|
||||
tag: 'Registrations',
|
||||
summary: 'Register for an event',
|
||||
description: [
|
||||
'Signed in, or as a guest for a free event that allows it (`guest` then required), or with a waitlist link (`waitlistToken`).',
|
||||
'Registering again for the same event adds to the existing registration. When nothing is owed it is marked paid and tickets are issued; otherwise pay with `POST /registrations/{id}/checkout`.',
|
||||
'A coupon that was valid when checked but used up a moment later is reported in `couponError`; the registration is still made.',
|
||||
].join('\n\n'),
|
||||
access: 'optional',
|
||||
status: 201,
|
||||
body: z.object({
|
||||
eventId: id,
|
||||
options: z.array(z.object({
|
||||
eventOptionId: id,
|
||||
quantity: z.number().int().min(1).max(1000),
|
||||
variantId: id.nullish(),
|
||||
sessionIds: z.array(id).max(200).optional().describe('"Pick sessions" tickets: the sessions chosen'),
|
||||
})).min(1).max(50),
|
||||
guest: z.object({ name: z.string().trim().min(1).max(200), email: z.email().max(200), phone: z.string().trim().max(40).optional() }).optional(),
|
||||
couponCode: z.string().trim().max(60).optional(),
|
||||
attendees: z.array(attendee).max(500).optional().describe('Names for the places (events that collect them)'),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional().describe('With attendees: tickets to the payer, or to each attendee'),
|
||||
waitlistToken: z.string().max(200).optional(),
|
||||
analyticsSessionId: z.string().max(100).optional(),
|
||||
}),
|
||||
response: single(registrationSummary.extend({
|
||||
isNew: z.boolean().describe('false = added to an existing registration'),
|
||||
couponError: z.string().nullable(),
|
||||
})),
|
||||
async handler({ body, user }) {
|
||||
const { registration, isNew, couponError } = await createRegistration({
|
||||
eventId: body.eventId,
|
||||
options: body.options.map(o => ({ ...o, variantId: o.variantId ?? null })),
|
||||
guestName: body.guest?.name,
|
||||
guestEmail: body.guest?.email,
|
||||
guestPhone: body.guest?.phone,
|
||||
couponCode: body.couponCode,
|
||||
attendees: body.attendees,
|
||||
ticketDelivery: body.ticketDelivery,
|
||||
waitlistToken: body.waitlistToken,
|
||||
analyticsSessionId: body.analyticsSessionId,
|
||||
}, user ? { id: user.id, role: user.role, email: user.email } : null);
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({ where: { id: registration.id }, include: SUMMARY_INCLUDE });
|
||||
return { data: { ...toSummary(fresh), isNew, couponError } };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRegistration = defineRoute({
|
||||
method: 'get',
|
||||
path: '/registrations/:id',
|
||||
tag: 'Registrations',
|
||||
summary: 'One of my registrations',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user }) {
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/cancel',
|
||||
tag: 'Registrations',
|
||||
summary: 'Cancel my registration',
|
||||
description: 'Not after the event has ended, and not once anything has been paid (the church can do that). Its places go to the waitlist.',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user, req }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
await cancelRegistration(params.id, user!, getClientIp(req));
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const checkout = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/checkout',
|
||||
tag: 'Registrations',
|
||||
summary: 'Pay what I owe online',
|
||||
description: [
|
||||
'Starts a payment with the church\'s provider (Yoco or PayFast) and returns where to send the person (`redirectUrl`).',
|
||||
'If an early-bird price ran out since the registration was made, nothing is started: `priceUpdated` is true with the new amount owing — show it, then call again.',
|
||||
].join('\n\n'),
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
body: z.object({
|
||||
donationAmount: z.number().min(0).max(100000).optional().describe('An optional donation added on top, in rand'),
|
||||
provider: z.string().max(40).optional().describe('yoco or payfast (default: the first one switched on)'),
|
||||
successUrl: z.url().max(2000).optional(),
|
||||
cancelUrl: z.url().max(2000).optional(),
|
||||
failureUrl: z.url().max(2000).optional(),
|
||||
}),
|
||||
response: single(z.discriminatedUnion('priceUpdated', [
|
||||
z.object({ priceUpdated: z.literal(true), newBalance: z.number(), message: z.string() }),
|
||||
z.object({ priceUpdated: z.literal(false), checkoutId: z.string(), redirectUrl: z.string(), provider: z.string(), amount: z.number().describe('Charged now, in rand (with any donation)') }),
|
||||
])),
|
||||
async handler({ params, body, user }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const result = await startRegistrationCheckout(params.id, user!, body);
|
||||
if (result.priceUpdated) return { data: { priceUpdated: true as const, newBalance: result.newTotal, message: result.message } };
|
||||
if (!result.redirectUrl) throw new ApiError('INTERNAL', 'The payment provider did not return a payment page.');
|
||||
return { data: { priceUpdated: false as const, checkoutId: result.checkoutId, redirectUrl: result.redirectUrl, provider: result.provider, amount: result.amount } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Request, RequestHandler, Response, Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from './errors';
|
||||
import auth from '../middleware/authMiddleware';
|
||||
|
||||
/**
|
||||
* Every /api/v2 endpoint is declared with `defineRoute`: method, path, who may call it, and zod
|
||||
* schemas for the params, query, body and response. The same declaration
|
||||
* - validates the request (400 VALIDATION_FAILED with per-field messages),
|
||||
* - checks the response against its schema and strips anything it doesn't list (so a field
|
||||
* can't leak by being added to a Prisma include), and
|
||||
* - becomes the OpenAPI document (openapi.ts) — the docs can't drift from the code.
|
||||
*/
|
||||
|
||||
export type Access = 'public' | 'optional' | 'user' | 'staff' | 'supervisor' | 'admin';
|
||||
|
||||
/** The signed-in person, as the auth middleware puts it on the request. */
|
||||
export type ApiUser = { id: string; name: string; email: string; role: 'admin' | 'supervisor' | 'staff' | 'user' };
|
||||
|
||||
export type Ctx<P, Q, B> = {
|
||||
params: P;
|
||||
query: Q;
|
||||
body: B;
|
||||
user: ApiUser | null;
|
||||
req: Request;
|
||||
res: Response;
|
||||
};
|
||||
|
||||
type Schema = z.ZodType;
|
||||
type Infer<S> = S extends z.ZodType ? z.infer<S> : undefined;
|
||||
|
||||
export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema> = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
/** Express-style, relative to /api/v2: "/events/:id". */
|
||||
path: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
tag: string;
|
||||
access: Access;
|
||||
/**
|
||||
* Staff and supervisor routes must say how they treat campus-scoped staff (see
|
||||
* utils/campusScope.js and tests/campusScopeRoutes.test.js) — e.g. "scopeEvent(params.id)".
|
||||
*/
|
||||
campusScope?: string;
|
||||
params?: P;
|
||||
query?: Q;
|
||||
body?: B;
|
||||
response: R;
|
||||
/** HTTP status on success (default 200). */
|
||||
status?: number;
|
||||
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyRoute = RouteDef<any, any, any, any>;
|
||||
|
||||
export const routes: AnyRoute[] = [];
|
||||
|
||||
const ACCESS: Record<Access, RequestHandler[]> = {
|
||||
public: [],
|
||||
optional: [auth.optionalAuth],
|
||||
user: [auth.protect],
|
||||
staff: [auth.protect, auth.staff],
|
||||
supervisor: [auth.protect, auth.supervisor],
|
||||
admin: [auth.protect, auth.admin],
|
||||
};
|
||||
|
||||
export function defineRoute<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema>(def: RouteDef<P, Q, B, R>): RouteDef<P, Q, B, R> {
|
||||
if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) {
|
||||
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`);
|
||||
}
|
||||
routes.push(def as AnyRoute);
|
||||
return def;
|
||||
}
|
||||
|
||||
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
|
||||
(schema ? schema.parse(value) : undefined) as Infer<S>;
|
||||
|
||||
/** Adds every defined route to the router. */
|
||||
export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
for (const def of list) {
|
||||
const run: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const ctx = {
|
||||
params: parse(def.params, req.params),
|
||||
query: parse(def.query, req.query),
|
||||
body: parse(def.body, req.body ?? {}),
|
||||
user: (req as Request & { user?: ApiUser }).user ?? null,
|
||||
req,
|
||||
res,
|
||||
};
|
||||
const result = await def.handler(ctx);
|
||||
const checked = def.response.safeParse(result);
|
||||
if (!checked.success) {
|
||||
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
|
||||
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
res.status(def.status ?? 200).json(checked.data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
router[def.method](def.path, ...ACCESS[def.access], run);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** Shared pieces of v2 schemas. Dates go out as ISO 8601 strings (UTC). */
|
||||
|
||||
export const isoDate = z.iso.datetime();
|
||||
export const id = z.string().min(1).max(64);
|
||||
export const idParams = z.object({ id: id.describe('The id') });
|
||||
|
||||
/** A Date (or date string) as ISO 8601, for building responses. */
|
||||
export function iso(d: Date | string): string;
|
||||
export function iso(d: Date | string | null | undefined): string | null;
|
||||
export function iso(d: Date | string | null | undefined): string | null {
|
||||
return d == null ? null : new Date(d).toISOString();
|
||||
}
|
||||
|
||||
/** A stored picture path ("/uploads/…") as an absolute URL on the host the request came to. */
|
||||
export function absoluteUrl(req: Request, url: string | null | undefined): string | null {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//.test(raw)) return raw;
|
||||
return `${req.protocol}://${req.get('host')}${raw.startsWith('/') ? '' : '/'}${raw}`;
|
||||
}
|
||||
|
||||
export const campusRef = z.object({ id, name: z.string(), slug: z.string() });
|
||||
export const seriesRef = z.object({ id, name: z.string(), slug: z.string() });
|
||||
export const eventRef = z.object({ id, title: z.string(), startDate: isoDate, endDate: isoDate });
|
||||
@@ -0,0 +1,40 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const { auditLogWhere } = require('../src/utils/adminAudit');
|
||||
|
||||
describe('auditLogWhere', () => {
|
||||
test('no filters → everything', () => {
|
||||
expect(auditLogWhere({})).toEqual({});
|
||||
});
|
||||
|
||||
test('a "to" date includes that whole day', () => {
|
||||
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
|
||||
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
|
||||
expect(where.createdAt.lte).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a full timestamp is used as given', () => {
|
||||
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
|
||||
});
|
||||
|
||||
test('action and role filter exactly', () => {
|
||||
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
|
||||
});
|
||||
|
||||
test('search looks at the person, the target and the IP', () => {
|
||||
const where = auditLogWhere({ search: ' #ab12 ' });
|
||||
expect(where.OR).toEqual([
|
||||
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
|
||||
{ targetId: { startsWith: 'ab12' } },
|
||||
{ ip: { startsWith: '#ab12' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a blank search is ignored', () => {
|
||||
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const mockDb = {
|
||||
registration: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
eventOption: { findMany: jest.fn() },
|
||||
attendee: { findMany: jest.fn(), updateMany: jest.fn() },
|
||||
registrationOption: { findMany: jest.fn(), update: jest.fn() },
|
||||
registrationOptionTranche: { deleteMany: jest.fn(), create: jest.fn() },
|
||||
user: { findUnique: jest.fn().mockResolvedValue({ role: "user" }) },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
const mockLeft = jest.fn().mockResolvedValue(null);
|
||||
jest.mock("../src/utils/stock", () => ({ remainingFor: (...a) => mockLeft(...a) }));
|
||||
jest.mock("../src/utils/pricing", () => ({ resolveOptionPrice: jest.fn().mockResolvedValue({ price: 150, tierId: null }) }));
|
||||
const mockStatus = jest.fn();
|
||||
jest.mock("../src/payments/webhook", () => ({ updateRegistrationStatus: (...a) => mockStatus(...a) }));
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn() }));
|
||||
const mockLicence = jest.fn().mockResolvedValue({ readOnly: false });
|
||||
jest.mock('../src/hub/licence', () => ({ licenceStatus: (...a) => mockLicence(...a) }));
|
||||
const mockCancel = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
jest.mock('../src/controllers/registrationController', () => ({
|
||||
cancelRegistration: (req, res) => mockCancel(req, res),
|
||||
updateRegistrationOptions: (req, res) => mockUpdate(req, res),
|
||||
}));
|
||||
const mockAssign = jest.fn();
|
||||
jest.mock('../src/controllers/allocationController', () => ({ assign: (req, res) => mockAssign(req, res) }));
|
||||
const mockEmail = jest.fn();
|
||||
jest.mock('../src/controllers/eventController', () => ({ emailEventAttendees: (req, res) => mockEmail(req, res), whatsappEventAttendees: jest.fn() }));
|
||||
|
||||
const bulk = require('../src/utils/bulkRegistrations');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
const req = { user: { id: 'sup', role: 'supervisor' } };
|
||||
const reg = (id, extra = {}) => ({ id, userId: `u-${id}`, eventId: 'ev1', status: 'pending', seasonPassPurchaseId: null, registrationOptions: [{ eventOptionId: 'adult', variantId: null, quantity: 1, sessionId: null }], ...extra });
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('the input is checked before anything runs', async () => {
|
||||
await expect(bulk.runBulk(req, { action: 'explode', registrationIds: ['a'] })).rejects.toThrow('Unknown');
|
||||
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: [] })).rejects.toThrow('Tick at least one');
|
||||
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: Array.from({ length: 501 }, (_, i) => `r${i}`) })).rejects.toThrow('At most 500');
|
||||
});
|
||||
|
||||
test('cancelling runs the normal cancel for each; already-cancelled ones are skipped; a refusal is reported', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { status: 'cancelled' }), reg('c')]);
|
||||
mockCancel.mockImplementation((r, res) => (r.params.id === 'c' ? res.status(403).json({ message: 'has payments' }) : res.json({ message: 'Registration cancelled' })));
|
||||
const out = await bulk.runBulk(req, { action: 'cancel', registrationIds: ['a', 'b', 'c', 'gone'] });
|
||||
expect(out).toMatchObject({ done: 1, skipped: 1, failed: 2 });
|
||||
expect(out.results.find(r => r.id === 'c')).toMatchObject({ ok: false, message: 'has payments' });
|
||||
expect(out.results.find(r => r.id === 'gone')).toMatchObject({ ok: false, message: 'Not found' });
|
||||
expect(mockCancel).toHaveBeenCalledTimes(2);
|
||||
expect(mockCancel.mock.calls[0][0].user).toBe(req.user);
|
||||
});
|
||||
|
||||
test('moving keeps the same lines and tickets, repriced; checks places; not other events or season passes', async () => {
|
||||
mockDb.eventOption.findMany.mockResolvedValue([
|
||||
{ id: 'adult', eventId: 'ev1', name: 'Adult', event: { id: 'ev1', cashupStatus: 'open' } },
|
||||
{ id: 'vip', eventId: 'ev1', name: 'VIP', event: { id: 'ev1', cashupStatus: 'open' } },
|
||||
]);
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { eventId: 'ev2' }), reg('c', { seasonPassPurchaseId: 'p1' }), reg('d')]);
|
||||
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'line1', quantity: 1 }]);
|
||||
mockLeft.mockResolvedValueOnce(null).mockResolvedValueOnce(0);
|
||||
mockStatus.mockResolvedValue({ totalDue: 150, totalPaid: 100 });
|
||||
const out = await bulk.runBulk(req, { action: 'moveOption', registrationIds: ['a', 'b', 'c', 'd'], fromOptionId: 'adult', toOptionId: 'vip' });
|
||||
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: true, message: 'Moved — R50.00 now owing' });
|
||||
expect(mockDb.registrationOption.update).toHaveBeenCalledWith({ where: { id: 'line1' }, data: { eventOptionId: 'vip', variantId: null, priceSnapshot: 150, appliedTierId: null } });
|
||||
expect(mockDb.attendee.updateMany).toHaveBeenCalledWith({ where: { registrationOptionId: 'line1' }, data: { eventOptionId: 'vip', variantId: null } });
|
||||
expect(out.results.find(r => r.id === 'b').message).toBe('Not for this event');
|
||||
expect(out.results.find(r => r.id === 'c').message).toMatch(/season pass/);
|
||||
expect(out.results.find(r => r.id === 'd')).toMatchObject({ ok: false, message: 'Only 0 places left on VIP' });
|
||||
});
|
||||
|
||||
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
|
||||
mockLicence.mockResolvedValueOnce({ readOnly: true });
|
||||
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
|
||||
});
|
||||
|
||||
test('allocating places each named person; a registration without names says so', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b')]);
|
||||
mockDb.attendee.findMany.mockImplementation(({ where }) => Promise.resolve(where.registrationId === 'a' ? [{ id: 'p1', name: 'Amy' }, { id: 'p2', name: 'Ben' }] : []));
|
||||
mockAssign.mockImplementation((r, res) => (r.body.attendeeId === 'p2' ? res.status(400).json({ message: 'Cabin 3 is full' }) : res.json({})));
|
||||
const out = await bulk.runBulk(req, { action: 'allocate', registrationIds: ['a', 'b'], setId: 's1', groupId: 'g1' });
|
||||
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: false, message: 'Ben: Cabin 3 is full' });
|
||||
expect(out.results.find(r => r.id === 'b').message).toMatch(/No named people/);
|
||||
});
|
||||
|
||||
test('messages go per event as an event update to exactly the ticked registrations', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b'), reg('c', { eventId: 'ev2' })]);
|
||||
mockEmail.mockImplementation((r, res) => res.json({ sent: r.body.filter.registrationIds.length }));
|
||||
const out = await bulk.runBulk(req, { action: 'message', registrationIds: ['a', 'b', 'c'], channel: 'email', subject: 'Bus times', text: 'The bus leaves at 7:30.' });
|
||||
expect(out.done).toBe(3);
|
||||
expect(mockEmail.mock.calls.map(c => [c[0].params.id, c[0].body.filter.registrationIds, c[0].body.kind])).toEqual([['ev1', ['a', 'b'], 'update'], ['ev2', ['c'], 'update']]);
|
||||
await expect(bulk.runBulk(req, { action: 'message', registrationIds: ['a'], channel: 'email', text: 'x' })).rejects.toThrow('subject');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const count = jest.fn().mockResolvedValue(2);
|
||||
const mockDb = {
|
||||
registration: { count, findMany: jest.fn().mockResolvedValue([]) },
|
||||
consentRequest: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
waitlistEntry: { count },
|
||||
volunteerSwap: { count },
|
||||
servingRole: { findMany: jest.fn().mockResolvedValue([{ needed: 3, _count: { slots: 1 } }, { needed: 2, _count: { slots: 2 } }]) },
|
||||
event: { count, findMany: jest.fn().mockResolvedValue([]) },
|
||||
providerTransaction: { count },
|
||||
dataRequest: { count },
|
||||
webhookDelivery: { count },
|
||||
payment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockScope = jest.fn();
|
||||
jest.mock('../src/utils/campusScope', () => ({
|
||||
scopeFor: (...a) => mockScope(...a),
|
||||
eventWhere: scope => (scope ? { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] } : {}),
|
||||
}));
|
||||
|
||||
const dashboard = require('../src/utils/dashboard');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('an amount typed in the palette', () => {
|
||||
expect(dashboard.amountOf('R450')).toBe(450);
|
||||
expect(dashboard.amountOf('r 450,50')).toBe(450.5);
|
||||
expect(dashboard.amountOf('450.5')).toBe(450.5);
|
||||
expect(dashboard.amountOf('Camp 2026')).toBeNull();
|
||||
expect(dashboard.amountOf('')).toBeNull();
|
||||
});
|
||||
|
||||
describe('what is waiting', () => {
|
||||
test('admins see everything they can act on', async () => {
|
||||
const items = await dashboard.pendingItems({ user: { role: 'admin' } }, null);
|
||||
expect(items.map(i => i.key)).toEqual(['privacy', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks']);
|
||||
// Only roles still short of people count.
|
||||
expect(items.find(i => i.key === 'roster').count).toBe(1);
|
||||
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/admin/registrations?status=pending');
|
||||
});
|
||||
|
||||
test('a campus-limited supervisor: no cashups, provider payments, privacy or webhooks; only their events', async () => {
|
||||
const scope = { campusIds: ['north'] };
|
||||
const items = await dashboard.pendingItems({ user: { role: 'supervisor' } }, scope);
|
||||
expect(items.map(i => i.key)).toEqual(['unpaid', 'cover', 'roster', 'waitlist']);
|
||||
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/supervisor/reports?report=regStatus');
|
||||
const where = mockDb.registration.count.mock.calls[0][0].where;
|
||||
expect(where.event.AND[0]).toEqual({ OR: [{ campusId: { in: ['north'] } }, { campusId: null }] });
|
||||
});
|
||||
|
||||
test('items with nothing waiting are left out', async () => {
|
||||
count.mockResolvedValue(0);
|
||||
mockDb.servingRole.findMany.mockResolvedValueOnce([]);
|
||||
expect(await dashboard.pendingItems({ user: { role: 'admin' } }, null)).toEqual([]);
|
||||
count.mockResolvedValue(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('palette search', () => {
|
||||
test('too short a query finds nothing', async () => {
|
||||
expect(await dashboard.search({ user: { role: 'admin' } }, 'a')).toEqual([]);
|
||||
});
|
||||
|
||||
test('staff get events only, linked to the public page', async () => {
|
||||
mockScope.mockResolvedValue(null);
|
||||
mockDb.event.findMany.mockResolvedValueOnce([{ id: 'e1', title: 'Camp 2026', startDate: new Date('2026-03-01'), isActive: true, campus: null }]);
|
||||
const out = await dashboard.search({ user: { role: 'staff' } }, 'camp');
|
||||
expect(out).toEqual([expect.objectContaining({ type: 'event', href: '/events/e1' })]);
|
||||
expect(mockDb.user.findMany).not.toHaveBeenCalled();
|
||||
expect(mockDb.payment.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('supervisors find payments by amount; admins also people', async () => {
|
||||
mockScope.mockResolvedValue(null);
|
||||
mockDb.payment.findMany.mockResolvedValueOnce([{ id: 'p1', amount: 450, method: 'card', createdAt: new Date(), registrationId: 'r1', user: { name: 'Thandi' }, event: { title: 'Camp' } }]);
|
||||
const sup = await dashboard.search({ user: { role: 'supervisor' } }, 'R450');
|
||||
expect(sup.find(r => r.type === 'payment')).toMatchObject({ title: 'R450.00 — Thandi', href: '/dashboard/supervisor/payments' });
|
||||
expect(mockDb.payment.findMany.mock.calls[0][0].where.amount).toEqual({ in: [450, -450] });
|
||||
expect(mockDb.user.findMany).not.toHaveBeenCalled();
|
||||
|
||||
mockDb.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Thandi Test', email: 't@test.local', role: 'user' }]);
|
||||
const adm = await dashboard.search({ user: { role: 'admin' } }, 'thandi');
|
||||
expect(adm.find(r => r.type === 'person')).toMatchObject({ href: '/dashboard/admin/users?q=t%40test.local' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
const { costCopies, setsToCopy } = require('../src/utils/eventClone');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('event cloning', () => {
|
||||
test('costs move to the new option with the same name, else to the whole event', () => {
|
||||
const costs = [
|
||||
{ id: 'c1', label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null, eventOption: null },
|
||||
{ id: 'c2', label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'old-a', eventOption: { name: 'Adult ' } },
|
||||
{ id: 'c3', label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: 'old-b', eventOption: { name: 'Leader' } },
|
||||
];
|
||||
const out = costCopies(costs, [{ id: 'new-a', name: 'adult' }, { id: 'new-c', name: 'Child' }]);
|
||||
expect(out).toEqual([
|
||||
{ label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null },
|
||||
{ label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'new-a' },
|
||||
{ label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: null },
|
||||
]);
|
||||
// Nothing of the source rows' identity is carried over.
|
||||
for (const c of out) expect(c.id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('kids rooms are not copied', () => {
|
||||
const sets = [{ id: 'rooms' }, { id: 'kids' }, { id: 'buses' }];
|
||||
expect(setsToCopy(sets, 'kids').map(s => s.id)).toEqual(['rooms', 'buses']);
|
||||
expect(setsToCopy(sets, null)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('copying setup is blocked in licence read-only mode', () => {
|
||||
expect(isBlocked('POST', '/api/events/abc/copy-from')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
||||
const { verifyToken, stateFor } = require('../src/hub/licence');
|
||||
const { incomeBand } = require('../src/hub/report');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
|
||||
const { compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices } = require('../src/controllers/hubController');
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
function sign(payload, key = privateKey) {
|
||||
@@ -100,6 +100,8 @@ describe('read-only mode', () => {
|
||||
['POST', '/api/payments/refund'],
|
||||
['POST', '/api/privacy/me/requests'],
|
||||
['POST', '/api/hub/connect'],
|
||||
['POST', '/api/hub/update-request/cancel'],
|
||||
['POST', '/api/hub/update-snooze'],
|
||||
['POST', '/api/users/login'],
|
||||
['POST', '/api/reports/pdf'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
@@ -146,8 +148,59 @@ test('version comparison for "update available"', () => {
|
||||
|
||||
test('an update request shows until the site runs the version asked for', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0' });
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0', outdated: false });
|
||||
expect(pendingUpdateRequest('1.42.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('a request for a version that has since been overtaken can be changed', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.0', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.0', outdated: true });
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.3', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.3', outdated: false });
|
||||
});
|
||||
|
||||
describe("what's new", () => {
|
||||
const releases = [
|
||||
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
|
||||
{ version: '1.48.0', notes: '- Webhooks', urgent: false, releasedAt: '2026-10-30T10:00:00.000Z' },
|
||||
{ version: '1.47.1', notes: '- Security fix', urgent: true, releasedAt: '2026-10-29T10:00:00.000Z' },
|
||||
{ version: 'bogus', notes: 'x' },
|
||||
];
|
||||
|
||||
test('only versions newer than this site, newest first', () => {
|
||||
expect(newerReleases(releases, '1.47.0').map(r => r.version)).toEqual(['1.48.0', '1.47.1']);
|
||||
expect(newerReleases(releases, '1.48.0')).toEqual([]);
|
||||
expect(newerReleases(null, '1.0.0')).toEqual([]);
|
||||
});
|
||||
|
||||
test('"Not now" holds until a newer version than the one put off', () => {
|
||||
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: '1.47.0', urgent: false })).toBe(true);
|
||||
expect(updateSnoozed({ latestVersion: '1.48.0', snoozedVersion: '1.47.0', urgent: false })).toBe(false);
|
||||
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: null, urgent: false })).toBe(false);
|
||||
});
|
||||
|
||||
test('urgent updates can\'t be put off', () => {
|
||||
expect(updateSnoozed({ latestVersion: '1.47.1', snoozedVersion: '1.47.1', urgent: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the provider's notices", () => {
|
||||
const NOW_N = new Date('2026-11-01T18:30:00Z');
|
||||
const n = (id, extra = {}) => ({ id, level: 'info', title: 'T', message: 'M', startsAt: null, endsAt: null, ...extra });
|
||||
|
||||
test('show between their start and end', () => {
|
||||
const notices = [
|
||||
n('now'),
|
||||
n('window', { startsAt: '2026-11-01T18:00:00Z', endsAt: '2026-11-01T20:00:00Z' }),
|
||||
n('later', { startsAt: '2026-11-02T18:00:00Z' }),
|
||||
n('ended', { endsAt: '2026-11-01T18:00:00Z' }),
|
||||
];
|
||||
expect(activeNotices(notices, NOW_N).map(x => x.id)).toEqual(['now', 'window']);
|
||||
});
|
||||
|
||||
test('an unknown level shows as information', () => {
|
||||
expect(activeNotices([n('x', { level: 'party' })], NOW_N)[0].level).toBe('info');
|
||||
expect(activeNotices([n('x', { level: 'maintenance' })], NOW_N)[0].level).toBe('maintenance');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Asking for an update again after a newer version came out: the request moves on to it — on the
|
||||
// Hub itself (1.5.0, `changed`), or by withdrawing and asking again with an older Hub.
|
||||
const mockSettings = new Map();
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: {
|
||||
findMany: jest.fn(async ({ where }) => [...mockSettings].filter(([key]) => where.key.in.includes(key)).map(([key, value]) => ({ key, value }))),
|
||||
upsert: jest.fn(async ({ where, create }) => { mockSettings.set(where.key, create.value); }),
|
||||
deleteMany: jest.fn(async ({ where }) => { mockSettings.delete(where.key); }),
|
||||
},
|
||||
}));
|
||||
jest.mock('../src/hub/report', () => ({ APP_VERSION: '1.46.2' }));
|
||||
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const hub = require('../src/hub/client');
|
||||
|
||||
const reply = (status, body) => ({ ok: status < 400, status, json: async () => body });
|
||||
|
||||
describe('changing an update request', () => {
|
||||
let calls;
|
||||
beforeEach(() => {
|
||||
mockSettings.clear();
|
||||
mockSettings.set('hub_install_id', 'install-1');
|
||||
mockSettings.set('hub_install_secret', encrypt('secret'));
|
||||
mockSettings.set('hub_update_requested_at', '2026-10-20T09:00:00.000Z');
|
||||
mockSettings.set('hub_update_requested_version', '1.47.0');
|
||||
calls = [];
|
||||
});
|
||||
afterEach(() => { delete global.fetch; });
|
||||
|
||||
test('Hub 1.5.0 moves the open request on to the latest version', async () => {
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: true, changed: true, previousVersion: '1.47.0' });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
expect(mockSettings.get('hub_update_requested_at')).toBe('2026-10-28T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('an older Hub (no `changed`): the old request is withdrawn and made again', async () => {
|
||||
let open = true;
|
||||
global.fetch = jest.fn(async url => {
|
||||
const path = new URL(url).pathname;
|
||||
calls.push(path);
|
||||
if (path.endsWith('/cancel')) { open = false; return reply(200, { cancelled: true }); }
|
||||
return open
|
||||
? reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true })
|
||||
: reply(201, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: false });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request', '/api/installs/update-request/cancel', '/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
});
|
||||
|
||||
test('asking again for the version already asked for changes nothing', async () => {
|
||||
mockSettings.set('hub_update_requested_version', '1.47.3');
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true });
|
||||
});
|
||||
await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
});
|
||||
});
|
||||
@@ -60,18 +60,23 @@ describe('cleanSections', () => {
|
||||
});
|
||||
|
||||
describe('noticePromptPending', () => {
|
||||
test('nobody is asked while no version needs re-acceptance', async () => {
|
||||
test('someone who never accepted any version is asked once (first)', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe('first');
|
||||
});
|
||||
|
||||
test('nobody else is asked while no version needs re-acceptance', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
test('asked when they accepted an older version than the newest one that needs it', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls.at(-1)[0].where).toEqual({ requiresAcceptance: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,3 +112,32 @@ describe('reportRowCount', () => {
|
||||
expect(reportRowCount(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes to the built-in text', () => {
|
||||
test('a site that never published shows the built-in text, so there is nothing to review', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a site with its own version is shown the changes it has not reviewed', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 2 });
|
||||
prisma.appSetting.findUnique.mockResolvedValue(null);
|
||||
expect((await notice.builtInUpdates()).map(u => u.revision)).toContain('1.48.1');
|
||||
prisma.appSetting.findUnique.mockResolvedValue({ value: notice.BUILT_IN_REVISION });
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('every changed section exists in the built-in text', () => {
|
||||
const ids = notice.defaultSections().map(s => s.id);
|
||||
for (const u of notice.BUILT_IN_UPDATES) for (const id of u.sections) expect(ids).toContain(id);
|
||||
expect(notice.revisionLess('1.47.9', '1.48.1')).toBe(true);
|
||||
expect(notice.revisionLess('1.48.10', '1.48.9')).toBe(false);
|
||||
});
|
||||
|
||||
test('the built-in text covers the features since 1.46', () => {
|
||||
const all = notice.defaultSections().map(s => s.body).join('\n');
|
||||
for (const words of ['volunteer', 'series you follow', 'season pass', 'webhook', 'cross_events_dismissed_notices', 'activate an account']) {
|
||||
expect([words, all.toLowerCase().includes(words.toLowerCase())]).toEqual([words, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const mockDb = {
|
||||
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const filters = require('../src/utils/savedFilters');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('only simple filter values are kept', () => {
|
||||
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
|
||||
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
|
||||
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
|
||||
});
|
||||
|
||||
test('pages and names are checked', async () => {
|
||||
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
|
||||
expect(() => filters.cleanName(' ')).toThrow('name');
|
||||
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
|
||||
});
|
||||
|
||||
test('saving a default clears the old default; saving the same name replaces it', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
|
||||
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
|
||||
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
|
||||
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
|
||||
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('at most 30 per page', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue(null);
|
||||
mockDb.savedFilter.count.mockResolvedValue(30);
|
||||
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
|
||||
});
|
||||
|
||||
test('someone else\'s saved filter is "not found"', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
|
||||
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const mockDb = {
|
||||
payment: { findFirst: jest.fn(), create: jest.fn() },
|
||||
seasonPassPurchase: { findUnique: jest.fn(), update: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockStatus = jest.fn();
|
||||
const mockSaveTx = jest.fn();
|
||||
jest.mock('../src/payments/webhook', () => ({
|
||||
updateRegistrationStatus: (...a) => mockStatus(...a),
|
||||
saveProviderTransaction: (...a) => mockSaveTx(...a),
|
||||
}));
|
||||
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn().mockResolvedValue() }));
|
||||
|
||||
const pass = require('../src/utils/seasonPass');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('season passes', () => {
|
||||
describe('splitting the price', () => {
|
||||
test('in proportion to normal prices, in whole cents that add up', () => {
|
||||
// R400 pass: a R300 camp and two R50 breakfasts.
|
||||
expect(pass.splitCents(40000, [30000, 5000, 5000])).toEqual([30000, 5000, 5000]);
|
||||
// R100 over three equal events: the odd cent goes to the first.
|
||||
expect(pass.splitCents(10000, [5000, 5000, 5000])).toEqual([3334, 3333, 3333]);
|
||||
const parts = pass.splitCents(9999, [1234, 5678, 91]);
|
||||
expect(parts.reduce((s, x) => s + x, 0)).toBe(9999);
|
||||
});
|
||||
|
||||
test('free tickets everywhere: split equally', () => {
|
||||
expect(pass.splitCents(9000, [0, 0, 0])).toEqual([3000, 3000, 3000]);
|
||||
});
|
||||
|
||||
test('a variant\'s own price counts, else the ticket\'s', () => {
|
||||
const items = [
|
||||
{ eventOption: { price: 300 }, variant: { price: 200 } },
|
||||
{ eventOption: { price: 50 }, variant: { price: null } },
|
||||
{ eventOption: { price: 50 }, variant: null },
|
||||
];
|
||||
expect(pass.itemShares(240, items)).toEqual([160, 40, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a payment pays what each registration owes, in order; anything over goes to the last', () => {
|
||||
expect(pass.allocate([160, 40, 40], 240)).toEqual([160, 40, 40]);
|
||||
expect(pass.allocate([160, 40, 40], 100)).toEqual([100, 0, 0]);
|
||||
expect(pass.allocate([0, 40, 40], 90)).toEqual([0, 40, 50]);
|
||||
});
|
||||
|
||||
describe('when a pass is on sale', () => {
|
||||
const now = new Date('2027-01-10T08:00:00Z');
|
||||
const item = (start, extra = {}) => ({ event: { title: 'Breakfast', isActive: true, cashupStatus: 'open', startDate: new Date(start), endDate: new Date(start), scheduleType: 'single', sessions: [], ...extra } });
|
||||
const base = { isActive: true, saleEndsAt: null, items: [item('2027-02-01T07:00:00Z'), item('2027-03-01T07:00:00Z')] };
|
||||
|
||||
test('on sale before the first event starts', () => {
|
||||
expect(pass.notOnSale(base, now)).toBeNull();
|
||||
});
|
||||
test('stops when the first event starts, at the sale end, or switched off', () => {
|
||||
expect(pass.notOnSale({ ...base, items: [item('2027-01-09T07:00:00Z'), ...base.items] }, now)).toMatch(/first event started/);
|
||||
expect(pass.notOnSale({ ...base, saleEndsAt: new Date('2027-01-01') }, now)).toMatch(/no longer on sale/);
|
||||
expect(pass.notOnSale({ ...base, isActive: false }, now)).toMatch(/isn't on sale/);
|
||||
expect(pass.notOnSale({ ...base, items: [item('2027-02-01T07:00:00Z', { registrationDeadline: new Date('2027-01-05') })] }, now)).toMatch(/has closed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recording the payment', () => {
|
||||
const regs = [
|
||||
{ id: 'r1', eventId: 'camp', status: 'pending', createdAt: 1, registrationOptions: [{ quantity: 2, priceSnapshot: 160, tranches: [{ quantity: 2, priceSnapshot: 160 }] }], payments: [] },
|
||||
{ id: 'r2', eventId: 'b1', status: 'pending', createdAt: 2, registrationOptions: [{ quantity: 2, priceSnapshot: 40, tranches: [{ quantity: 2, priceSnapshot: 40 }] }], payments: [] },
|
||||
];
|
||||
const event = { transaction: { externalId: 'ext-1', amountCents: 40000, status: 'succeeded', methodType: 'card', metadata: { type: 'season_pass', purchaseId: 'pp1' } } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb.payment.findFirst.mockResolvedValue(null);
|
||||
mockDb.seasonPassPurchase.findUnique.mockResolvedValue({ id: 'pp1', userId: 'u1', registrations: regs });
|
||||
mockDb.payment.create.mockImplementation(({ data }) => Promise.resolve(data));
|
||||
mockStatus.mockResolvedValue({ status: 'paid' });
|
||||
mockSaveTx.mockResolvedValue();
|
||||
});
|
||||
|
||||
test('one payment per event for its share; only the first carries the provider id', async () => {
|
||||
const out = await pass.recordPassPayment('yoco', event, {});
|
||||
const made = mockDb.payment.create.mock.calls.map(c => c[0].data);
|
||||
expect(made.map(p => [p.registrationId, p.eventId, p.amount, p.externalId, p.seasonPassPurchaseId])).toEqual([
|
||||
['r1', 'camp', 320, 'ext-1', 'pp1'],
|
||||
['r2', 'b1', 80, null, 'pp1'],
|
||||
]);
|
||||
expect(made.every(p => p.isDonation === undefined && p.originalPaymentId === undefined)).toBe(true);
|
||||
expect(mockStatus).toHaveBeenCalledTimes(2);
|
||||
expect(mockDb.seasonPassPurchase.update).toHaveBeenCalledWith({ where: { id: 'pp1' }, data: { status: 'paid' } });
|
||||
expect(mockSaveTx.mock.calls[0][3]).toEqual({ reconciled: true, paymentId: made[0].id });
|
||||
expect(out.status).toBe('success');
|
||||
});
|
||||
|
||||
test('the same provider payment twice is recorded once', async () => {
|
||||
mockDb.payment.findFirst.mockResolvedValue({ id: 'already' });
|
||||
const out = await pass.recordPassPayment('yoco', event, {});
|
||||
expect(out).toMatchObject({ status: 'already_processed', paymentId: 'already' });
|
||||
expect(mockDb.payment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('a registration from a pass can\'t be edited or take a code', async () => {
|
||||
mockDb.registration.findUnique.mockResolvedValue({ seasonPassPurchaseId: 'pp1' });
|
||||
const res = { status: jest.fn(() => res), json: jest.fn() };
|
||||
const next = jest.fn();
|
||||
pass.notOnSeasonPass({ params: { id: 'r1' } }, res, next);
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('making, changing and buying passes is blocked in licence read-only mode', () => {
|
||||
expect(isBlocked('POST', '/api/season-passes')).toBe(true);
|
||||
expect(isBlocked('PUT', '/api/season-passes/p1')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/season-passes/p1/buy')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/season-passes/purchases/pp1/checkout')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), updateMany: jest.fn() },
|
||||
seriesFollow: { findMany: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockFilter = jest.fn();
|
||||
jest.mock('../src/utils/privacy', () => ({
|
||||
filterRecipients: (...a) => mockFilter(...a),
|
||||
bulkEmailFooter: () => ({ html: '<p>unsubscribe</p>', text: '\nunsubscribe', headers: { 'List-Unsubscribe': '<x>' } }),
|
||||
}));
|
||||
const mockSend = jest.fn();
|
||||
jest.mock('../src/utils/email', () => ({
|
||||
sendMail: (...a) => mockSend(...a),
|
||||
emailWrapper: body => `<html>${body}</html>`,
|
||||
ctaButton: (label, url) => `<a href="${url}">${label}</a>`,
|
||||
}));
|
||||
|
||||
const series = require('../src/utils/series');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('event series', () => {
|
||||
test('a new series needs a name; the web address comes from it', () => {
|
||||
expect(() => series.cleanSeriesInput({ name: ' ' })).toThrow('name');
|
||||
expect(series.cleanSeriesInput({ name: "Men's Breakfast", description: ' Monthly ' })).toEqual({ name: "Men's Breakfast", slug: 'men-s-breakfast', description: 'Monthly' });
|
||||
expect(series.cleanSeriesInput({ name: 'Camp', slug: 'Family Camp!' }).slug).toBe('family-camp');
|
||||
});
|
||||
|
||||
test('changing a series only touches what was sent', () => {
|
||||
expect(series.cleanSeriesInput({ description: '' }, { name: 'Camp' })).toEqual({ description: null });
|
||||
});
|
||||
|
||||
test('upcoming soonest first, past latest first', () => {
|
||||
const now = new Date('2026-06-01T00:00:00Z');
|
||||
const ev = (id, start, end) => ({ id, startDate: new Date(start), endDate: new Date(end) });
|
||||
const { upcoming, past } = series.splitEvents([
|
||||
ev('2024', '2024-03-01', '2024-03-04'), ev('2027', '2027-03-01', '2027-03-04'),
|
||||
ev('2025', '2025-03-01', '2025-03-04'), ev('2026', '2026-05-30', '2026-06-02'),
|
||||
], now);
|
||||
expect(upcoming.map(e => e.id)).toEqual(['2026', '2027']);
|
||||
expect(past.map(e => e.id)).toEqual(['2025', '2024']);
|
||||
});
|
||||
|
||||
test('creating and changing series is blocked in licence read-only mode; following is not', () => {
|
||||
expect(isBlocked('POST', '/api/series')).toBe(true);
|
||||
expect(isBlocked('PUT', '/api/series/abc')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
|
||||
});
|
||||
|
||||
describe('year-on-year comparison', () => {
|
||||
test('each event against the one before it', () => {
|
||||
const rows = series.withChanges([
|
||||
{ registrations: 40, tickets: 50, income: 10000, checkedIn: 45 },
|
||||
{ registrations: 50, tickets: 50, income: 9000, checkedIn: 0 },
|
||||
{ registrations: 25, tickets: 60, income: 9000, checkedIn: 30 },
|
||||
]);
|
||||
expect(rows[0].change).toBeNull();
|
||||
expect(rows[1].change).toEqual({ registrations: 25, tickets: 0, income: -10, checkedIn: -100 });
|
||||
// Nothing to compare with after a zero.
|
||||
expect(rows[2].change).toEqual({ registrations: -50, tickets: 20, income: 0, checkedIn: null });
|
||||
});
|
||||
|
||||
test('counts per event, check-ins averaged per session, only events in scope', async () => {
|
||||
const db = {
|
||||
eventSeries: { findUnique: jest.fn().mockResolvedValue({ id: 's1', name: 'Family Camp', slug: 'family-camp' }) },
|
||||
event: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'e25', title: 'Camp 2025', startDate: new Date('2025-03-01'), endDate: new Date('2025-03-04'), scheduleType: 'single', isActive: true, campus: null, _count: { sessions: 0 } },
|
||||
{ id: 'e26', title: 'Camp 2026', startDate: new Date('2026-03-01'), endDate: new Date('2026-03-04'), scheduleType: 'sessions', isActive: true, campus: null, _count: { sessions: 3 } },
|
||||
]),
|
||||
},
|
||||
registration: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _count: { _all: 40 } }, { eventId: 'e26', _count: { _all: 50 } }]) },
|
||||
ticket: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { quantity: 50 } }, { eventId: 'e26', _sum: { quantity: 60 } }]) },
|
||||
payment: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { amount: 10000 } }, { eventId: 'e26', _sum: { amount: 12000.456 } }]) },
|
||||
ticketUsage: { aggregate: jest.fn(({ where }) => Promise.resolve({ _sum: { quantityRedeemed: where.ticket.eventId === 'e25' ? 45 : 150 } })) },
|
||||
};
|
||||
const scopeWhere = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
|
||||
const out = await series.compareSeries('s1', { scopeWhere }, db);
|
||||
expect(db.event.findMany.mock.calls[0][0].where).toEqual({ AND: [{ seriesId: 's1' }, scopeWhere] });
|
||||
expect(out.events.map(e => [e.title, e.registrations, e.tickets, e.income, e.checkedIn, e.attendanceRate])).toEqual([
|
||||
['Camp 2025', 40, 50, 10000, 45, 90],
|
||||
['Camp 2026', 50, 60, 12000.46, 50, 83], // 150 check-ins over 3 sessions
|
||||
]);
|
||||
expect(out.events[1].change).toEqual({ registrations: 25, tickets: 20, income: 20, checkedIn: 11 });
|
||||
});
|
||||
|
||||
test('an unknown series is a 404', async () => {
|
||||
const db = { eventSeries: { findUnique: jest.fn().mockResolvedValue(null) } };
|
||||
await expect(series.compareSeries('nope', {}, db)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('announcing a new event to followers', () => {
|
||||
const now = new Date('2026-06-01T08:00:00Z');
|
||||
const event = { id: 'e1', title: 'Camp 2027', startDate: new Date('2027-03-01T08:00:00Z'), location: 'The farm', series: { id: 's1', name: 'Family Camp', slug: 'family-camp' } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb.event.findMany.mockResolvedValue([event]);
|
||||
mockDb.seriesFollow.findMany.mockResolvedValue([
|
||||
{ user: { id: 'u1', name: 'Thandi', email: 'thandi@test.local' } },
|
||||
{ user: { id: 'u2', name: 'No News', email: 'nonews@test.local' } },
|
||||
]);
|
||||
// Only Thandi opted in to news.
|
||||
mockFilter.mockImplementation(async list => ({ recipients: list.filter(r => r.userId === 'u1'), skipped: list.length - 1 }));
|
||||
mockSend.mockResolvedValue();
|
||||
});
|
||||
|
||||
test('only public events in a series that have not started and were not announced yet', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
|
||||
await series.runAnnounceJob(now);
|
||||
const where = mockDb.event.findMany.mock.calls[0][0].where;
|
||||
expect(where).toMatchObject({ isActive: true, isHidden: false, goLiveAt: { lte: now }, seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } });
|
||||
});
|
||||
|
||||
test('emails followers who want news, as news', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
|
||||
const out = await series.runAnnounceJob(now);
|
||||
expect(out).toEqual({ announced: 1, sent: 1, skipped: 1 });
|
||||
expect(mockFilter.mock.calls[0][1]).toEqual({ channel: 'email', kind: 'news' });
|
||||
const mail = mockSend.mock.calls[0][0];
|
||||
expect(mail.to).toBe('thandi@test.local');
|
||||
expect(mail.subject).toBe('New in Family Camp: Camp 2027');
|
||||
expect(mail.html).toContain('/events/e1');
|
||||
expect(mail.html).toContain('unsubscribe');
|
||||
expect(mail.headers['List-Unsubscribe']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('an event another run already claimed is not sent again', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 0 });
|
||||
const out = await series.runAnnounceJob(now);
|
||||
expect(out).toEqual({ announced: 0, sent: 0, skipped: 0 });
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
// /api/v2 (1.51.0): the route declarations validate requests, shape responses and errors the same
|
||||
// way everywhere, and produce the OpenAPI document.
|
||||
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/campuses', () => ({
|
||||
campusesEnabled: jest.fn().mockResolvedValue(false),
|
||||
resolveCampusFilter: jest.fn(async (v: string) => v),
|
||||
campusWhere: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// Signed in as whoever the test says (the real middleware checks JWTs against the database).
|
||||
let signedIn: { id: string; role: string } | null = null;
|
||||
jest.mock('../src/middleware/authMiddleware', () => {
|
||||
const deny = (status: number, message: string) => (_req: unknown, res: { status(n: number): void }, next: (e: Error) => void) => { res.status(status); next(new Error(message)); };
|
||||
return {
|
||||
protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)),
|
||||
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
|
||||
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
|
||||
staff: jest.fn(), supervisor: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// The services are tested through v1 (e2e); here only how v2 calls them and maps their answers.
|
||||
const mockServices = { createRegistration: jest.fn(), cancelRegistration: jest.fn(), startRegistrationCheckout: jest.fn() };
|
||||
jest.mock('../src/services/registrations', () => ({ createRegistration: (...a: unknown[]) => mockServices.createRegistration(...a), cancelRegistration: (...a: unknown[]) => mockServices.cancelRegistration(...a) }));
|
||||
jest.mock('../src/services/payments', () => ({ startRegistrationCheckout: (...a: unknown[]) => mockServices.startRegistrationCheckout(...a) }));
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v2', v2Router());
|
||||
|
||||
const event = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
|
||||
location: 'Camp Joy', price: 600, picture: '/uploads/events/camp.jpg', requiresRegistration: true, scheduleType: 'single',
|
||||
campus: null, series: { id: 's1', name: 'Camps', slug: 'camps' }, ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => { signedIn = null; jest.clearAllMocks(); });
|
||||
|
||||
describe('GET /api/v2/events', () => {
|
||||
test('a page of public events, with meta and ISO dates', async () => {
|
||||
mockDb.event.findMany.mockResolvedValue([event()]);
|
||||
mockDb.event.count.mockResolvedValue(31);
|
||||
const res = await request(app).get('/api/v2/events?page=2&limit=10&q=camp');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.meta).toEqual({ page: 2, limit: 10, total: 31, pages: 4 });
|
||||
expect(res.body.data[0]).toMatchObject({ id: 'e1', startDate: '2026-12-04T16:00:00.000Z', series: { slug: 'camps' } });
|
||||
expect(res.body.data[0].pictureUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/uploads\/events\/camp\.jpg$/);
|
||||
const args = mockDb.event.findMany.mock.calls[0][0];
|
||||
expect(args).toMatchObject({ skip: 10, take: 10 });
|
||||
expect(args.where.AND).toEqual(expect.arrayContaining([{ isActive: true, isHidden: false, goLiveAt: { lte: expect.any(Date) } }, { title: { contains: 'camp', mode: 'insensitive' } }]));
|
||||
});
|
||||
|
||||
test('fields the schema does not list are never sent', async () => {
|
||||
mockDb.event.findMany.mockResolvedValue([event({ cashupStatus: 'closed', createdById: 'u9' })]);
|
||||
mockDb.event.count.mockResolvedValue(1);
|
||||
const res = await request(app).get('/api/v2/events');
|
||||
expect(Object.keys(res.body.data[0]).sort()).toEqual(['campus', 'endDate', 'id', 'location', 'pictureUrl', 'price', 'requiresRegistration', 'scheduleType', 'series', 'startDate', 'title']);
|
||||
});
|
||||
|
||||
test('a bad query is a VALIDATION_FAILED error naming the field', async () => {
|
||||
const res = await request(app).get('/api/v2/events?limit=500&page=0');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe('VALIDATION_FAILED');
|
||||
expect(Object.keys(res.body.error.fields).sort()).toEqual(['limit', 'page']);
|
||||
expect(mockDb.event.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v2/events/:id', () => {
|
||||
test('leaves out kids and volunteer tickets and shows the early-bird price now', async () => {
|
||||
mockDb.event.findFirst.mockResolvedValue({
|
||||
...event(), description: 'A weekend away', registrationDeadline: null, kidsOptionId: 'kids', volunteerOptionId: null,
|
||||
eventOptions: [
|
||||
{ id: 'o1', name: 'Camper', price: 600, isMainTicket: true, sessionMode: 'all', earlyBirdTiers: [{ id: 't', price: 500, deadline: new Date(Date.now() + 864e5), stockLimit: 0 }] },
|
||||
{ id: 'kids', name: 'Kids check-in', price: 0, isMainTicket: false, sessionMode: 'all', earlyBirdTiers: [] },
|
||||
],
|
||||
sessions: [],
|
||||
});
|
||||
const res = await request(app).get('/api/v2/events/e1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.ticketOptions).toEqual([{ id: 'o1', name: 'Camper', price: 600, currentPrice: 500, isMainTicket: true, sessionMode: 'all' }]);
|
||||
});
|
||||
|
||||
test('an unknown or private event is NOT_FOUND', async () => {
|
||||
mockDb.event.findFirst.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/v2/events/nope');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: "That event wasn't found." } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('me', () => {
|
||||
test('signed out is UNAUTHENTICATED in the v2 shape', async () => {
|
||||
const res = await request(app).get('/api/v2/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error.code).toBe('UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
test('my registrations with totals and what is owing', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findMany.mockResolvedValue([{
|
||||
id: 'r1', status: 'partial_paid', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
|
||||
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
|
||||
registrationOptions: [{ quantity: 2, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
|
||||
payments: [{ amount: 500, createdAt: new Date() }],
|
||||
}]);
|
||||
mockDb.registration.count.mockResolvedValue(1);
|
||||
const res = await request(app).get('/api/v2/me/registrations');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data[0]).toMatchObject({ id: 'r1', totalDue: 1200, amountPaid: 500, balance: 700, items: [{ ticket: 'Camper', variant: null, quantity: 2, unitPrice: 600 }] });
|
||||
expect(mockDb.registration.findMany.mock.calls[0][0].where).toMatchObject({ userId: 'u1', status: { not: 'cancelled' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the API description', () => {
|
||||
test('openapi.json is for admins', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/openapi.json')).body.error.code).toBe('FORBIDDEN');
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).get('/api/v2/openapi.json');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.openapi).toBe('3.1.0');
|
||||
});
|
||||
|
||||
test('every route is described, with its parameters, auth and response', () => {
|
||||
const doc = buildOpenApi({ version: 'test' });
|
||||
for (const r of routes) {
|
||||
const op = (doc.paths as Record<string, Record<string, Record<string, unknown>>>)[r.path.replace(/:(\w+)/g, '{$1}')][r.method];
|
||||
expect(op).toBeDefined();
|
||||
expect(op.responses).toHaveProperty(String(r.status ?? 200));
|
||||
}
|
||||
const list = doc.paths['/events'].get as { parameters: { name: string; in: string }[]; security?: unknown };
|
||||
expect(list.parameters.map(p => p.name)).toEqual(['page', 'limit', 'q', 'campus', 'series']);
|
||||
expect(list.security).toBeUndefined();
|
||||
expect((doc.paths['/me'].get as { security: unknown }).security).toEqual([{ bearerAuth: [] }]);
|
||||
expect((doc.paths['/events/{id}'].get as { parameters: { name: string; in: string; required: boolean }[] }).parameters[0]).toMatchObject({ name: 'id', in: 'path', required: true });
|
||||
});
|
||||
|
||||
test('an unknown v2 path is a v2 NOT_FOUND', async () => {
|
||||
const res = await request(app).get('/api/v2/nothing-here');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registrations', () => {
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'r1', userId: 'u1', status: 'pending', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
|
||||
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
|
||||
registrationOptions: [{ quantity: 1, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
|
||||
payments: [], ...over,
|
||||
});
|
||||
|
||||
test('registering passes a guest through and answers 201 with what is owing', async () => {
|
||||
mockServices.createRegistration.mockResolvedValue({ registration: { id: 'r1' }, isNew: true, couponError: null });
|
||||
mockDb.registration.findUniqueOrThrow.mockResolvedValue(row());
|
||||
const res = await request(app).post('/api/v2/registrations').send({
|
||||
eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }], guest: { name: 'Thandi', email: 'thandi@example.com' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', isNew: true, couponError: null, totalDue: 600, balance: 600 });
|
||||
expect(mockServices.createRegistration).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e1', guestName: 'Thandi', guestEmail: 'thandi@example.com' }), null);
|
||||
});
|
||||
|
||||
test('a bad body never reaches the service', async () => {
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 0 }], guest: { name: 'T', email: 'not-an-email' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(Object.keys(res.body.error.fields).sort()).toEqual(['guest.email', 'options.0.quantity']);
|
||||
expect(mockServices.createRegistration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the service's errors keep their meaning", async () => {
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(401, 'Authentication required for this event'));
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toEqual({ code: 'UNAUTHENTICATED', message: 'Authentication required for this event' });
|
||||
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(400, '"Camper" is sold out or does not have enough stock (0 remaining).'));
|
||||
const sold = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(sold.body.error.code).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
test("someone else's registration is NOT_FOUND, and can't be cancelled or paid", async () => {
|
||||
signedIn = { id: 'u2', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
for (const [method, path] of [['get', '/api/v2/registrations/r1'], ['post', '/api/v2/registrations/r1/cancel'], ['post', '/api/v2/registrations/r1/checkout']] as const) {
|
||||
const res = await request(app)[method](path).send({});
|
||||
expect([path, res.status]).toEqual([path, 404]);
|
||||
}
|
||||
expect(mockServices.cancelRegistration).not.toHaveBeenCalled();
|
||||
expect(mockServices.startRegistrationCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancelling my registration', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValueOnce(row()).mockResolvedValueOnce(row({ status: 'cancelled' }));
|
||||
mockServices.cancelRegistration.mockResolvedValue({});
|
||||
const res = await request(app).post('/api/v2/registrations/r1/cancel');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('checkout: the payment page, or the new balance when an early-bird price ran out', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650, registration: {} });
|
||||
const pay = await request(app).post('/api/v2/registrations/r1/checkout').send({ donationAmount: 50 });
|
||||
expect(pay.body.data).toEqual({ priceUpdated: false, checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650 });
|
||||
expect(mockServices.startRegistrationCheckout).toHaveBeenCalledWith('r1', signedIn, { donationAmount: 50 });
|
||||
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ priceUpdated: true, newTotal: 700, message: 'Prices changed' });
|
||||
const changed = await request(app).post('/api/v2/registrations/r1/checkout').send({});
|
||||
expect(changed.body.data).toEqual({ priceUpdated: true, newBalance: 700, message: 'Prices changed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the frontend types', () => {
|
||||
test('frontend/src/lib/api-v2/openapi.json is up to date (run `npm run api:types` in the repo root)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { OPENAPI_FILE, currentDocument } = require('../scripts/openapi');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const saved = require('fs').readFileSync(OPENAPI_FILE, 'utf8').replace(/\r\n/g, '\n');
|
||||
expect(saved).toBe(currentDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
const mockDb = {
|
||||
webhookEndpoint: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) },
|
||||
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
payment: { findUnique: jest.fn() },
|
||||
ticketUsage: { findUnique: jest.fn() },
|
||||
event: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const webhooks = require('../src/utils/webhooks');
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
|
||||
describe('outbound webhooks', () => {
|
||||
afterEach(() => { delete global.fetch; delete process.env.WEBHOOKS_ALLOW_PRIVATE; jest.clearAllMocks(); });
|
||||
|
||||
describe('where they may go', () => {
|
||||
test('private, loopback and link-local addresses are refused', () => {
|
||||
for (const ip of ['10.1.2.3', '127.0.0.1', '192.168.0.10', '172.16.5.5', '172.31.255.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) {
|
||||
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, true]);
|
||||
}
|
||||
for (const ip of ['8.8.8.8', '172.32.0.1', '93.184.216.34', '2606:4700::1111']) {
|
||||
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, false]);
|
||||
}
|
||||
});
|
||||
|
||||
test('https only, no passwords in the address, no private hosts', () => {
|
||||
expect(webhooks.cleanUrl(' https://hooks.example.com/x?y=1 ')).toBe('https://hooks.example.com/x?y=1');
|
||||
expect(() => webhooks.cleanUrl('http://hooks.example.com/x')).toThrow('https://');
|
||||
expect(() => webhooks.cleanUrl('https://user:pw@hooks.example.com/')).toThrow('password');
|
||||
expect(() => webhooks.cleanUrl('https://localhost/x')).toThrow('private');
|
||||
expect(() => webhooks.cleanUrl('https://10.0.0.5/x')).toThrow('private');
|
||||
expect(() => webhooks.cleanUrl('not a url')).toThrow('https://');
|
||||
});
|
||||
|
||||
test('local testing can allow http and private addresses', () => {
|
||||
process.env.WEBHOOKS_ALLOW_PRIVATE = 'true';
|
||||
expect(webhooks.cleanUrl('http://localhost:5199/hook')).toBe('http://localhost:5199/hook');
|
||||
});
|
||||
});
|
||||
|
||||
test('the signature can be checked by the receiver', () => {
|
||||
const body = JSON.stringify({ id: 'evt_1', type: 'registration.paid' });
|
||||
const header = webhooks.signatureHeader('whsec_abc', body, 1_800_000_000_000);
|
||||
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
|
||||
expect(t).toBe('1800000000');
|
||||
expect(v1).toBe(crypto.createHmac('sha256', 'whsec_abc').update(`${t}.${body}`).digest('hex'));
|
||||
});
|
||||
|
||||
test('retries after 1 min, 5 min, 30 min, 2 h and 12 h, then gives up', () => {
|
||||
expect([1, 2, 3, 4, 5, 6].map(webhooks.retryDelay)).toEqual([60e3, 300e3, 1800e3, 7200e3, 43200e3, null]);
|
||||
});
|
||||
|
||||
describe('sending', () => {
|
||||
const delivery = (extra = {}) => ({
|
||||
id: 'd1', endpointId: 'ep1', type: 'registration.paid', attempts: 0, payload: { id: 'evt_1', type: 'registration.paid', data: {} },
|
||||
endpoint: { id: 'ep1', url: 'https://93.184.216.34/hook', isActive: true, secret: encrypt('whsec_s') }, ...extra,
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockDb.webhookDelivery.update.mockImplementation(async ({ data }) => ({ id: 'd1', ...data }));
|
||||
});
|
||||
|
||||
test('a 2xx answer is delivered, signed, without following redirects', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 204 });
|
||||
const out = await webhooks.send('d1');
|
||||
expect(out.status).toBe('succeeded');
|
||||
const [, init] = global.fetch.mock.calls[0];
|
||||
expect(init.redirect).toBe('manual');
|
||||
expect(init.headers['X-CrossEvents-Event']).toBe('registration.paid');
|
||||
expect(init.headers['X-CrossEvents-Signature']).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/);
|
||||
expect(mockDb.webhookEndpoint.update.mock.calls[0][0].data).toHaveProperty('lastSuccessAt');
|
||||
});
|
||||
|
||||
test('a failure is retried later, and given up after the last try', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 500 });
|
||||
expect((await webhooks.send('d1'))).toMatchObject({ status: 'retrying', attempts: 1, lastStatusCode: 500, lastError: 'Answered 500' });
|
||||
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery({ attempts: 5 }));
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
expect((await webhooks.send('d1'))).toMatchObject({ status: 'failed', attempts: 6, lastError: 'connect ECONNREFUSED' });
|
||||
});
|
||||
|
||||
test('a redirect is not delivered', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 302 });
|
||||
expect((await webhooks.send('d1')).lastError).toMatch(/redirects are not followed/);
|
||||
});
|
||||
|
||||
test('a delivery someone else is already sending is left alone', async () => {
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
|
||||
global.fetch = jest.fn();
|
||||
expect(await webhooks.send('d1')).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('what happened is checked again, then one delivery per endpoint that wants it', async () => {
|
||||
webhooks.forgetEndpoints();
|
||||
mockDb.webhookEndpoint.findMany.mockResolvedValue([{ id: 'a', events: ['user.created'] }, { id: 'b', events: ['registration.paid'] }, { id: 'c', events: ['user.created', 'event.published'] }]);
|
||||
mockDb.user.findUnique.mockResolvedValue({ id: 'u1', name: 'Thandi', email: 't@test.local', phoneNumber: null, role: 'user', createdAt: new Date() });
|
||||
// A registration that was paid but changed back before the check: nothing is sent.
|
||||
mockDb.registration.findUnique.mockResolvedValue({ id: 'r1', status: 'pending' });
|
||||
mockDb.webhookDelivery.create.mockImplementation(async ({ data }) => ({ id: `d-${data.endpointId}`, ...data }));
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
|
||||
webhooks.notice('user.created', 'u1');
|
||||
webhooks.notice('registration.paid', 'r1');
|
||||
webhooks.notice('not.a.type', 'x');
|
||||
const made = await webhooks.flush();
|
||||
expect(made.map(d => [d.endpointId, d.type, d.userId])).toEqual([['a', 'user.created', 'u1'], ['c', 'user.created', 'u1']]);
|
||||
expect(made[0].eventId).toBe(made[1].eventId);
|
||||
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
|
||||
});
|
||||
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
|
||||
test('each event type has an example shaped like the real payload', async () => {
|
||||
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
|
||||
: Array.isArray(v) ? v.map(shape) : '·');
|
||||
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
|
||||
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
|
||||
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
|
||||
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
|
||||
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
|
||||
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
|
||||
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
|
||||
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
|
||||
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
|
||||
for (const t of webhooks.EVENT_TYPES) {
|
||||
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
|
||||
mockDb.registration.findUnique.mockResolvedValue(reg(status));
|
||||
const built = await webhooks.buildPayload(t.type, 'x');
|
||||
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
// The backend is moving to TypeScript a file at a time (1.51.0): .js files are compiled as they
|
||||
// are (not type-checked), .ts files are checked strictly. `npm run build` writes dist/, which is
|
||||
// what production runs (dist/index.js); dev and the e2e tests run src/ through tsx.
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2023"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+227
-10
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.46.1** (and Hub 1.1.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.51.1** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,8 +14,25 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
|
||||
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
|
||||
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
|
||||
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
|
||||
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
|
||||
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
|
||||
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
|
||||
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
|
||||
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
|
||||
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
|
||||
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
|
||||
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
@@ -46,19 +63,30 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.47.0 — Event series & cloning** (§6), then **1.48.0 — outbound webhooks** (§8).
|
||||
1. **1.51.2 — v2 check-in and accounts** (ticket scan and manual check-in, sign up, log in,
|
||||
profile; move them into TS services as 1.51.1 did), then **1.51.3 — the rest of v2, Express 5,
|
||||
v1 deprecation headers and v2-shaped global middleware**, then **2.0.0 — AI helpers** (§15).
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
|
||||
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
|
||||
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
|
||||
in charged rand. AI endpoints are `/api/v2` only.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
- **Release routine:** branch `feat/<name>` → commit → bump with
|
||||
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → write the church-facing
|
||||
`## X.Y.Z` section in **WHATS-NEW.md** (plain language; `— urgent` in the heading for a security
|
||||
or serious fix) → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
|
||||
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
|
||||
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/app.js` (v2 documents itself), and
|
||||
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
|
||||
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
|
||||
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
|
||||
@@ -156,7 +184,15 @@ a test server (both done 2026-10-28).
|
||||
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
|
||||
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
|
||||
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
|
||||
changes the live notice only for installs that haven't published a version yet.**
|
||||
changes the live notice only for installs that haven't published a version yet** — so every
|
||||
change to `DEFAULT_SECTIONS` must also add an entry to `BUILT_IN_UPDATES` (the release, a summary,
|
||||
the section ids changed). Sites with their own version then see it in the editor until they press
|
||||
"Done — hide this" (AppSetting `privacy_notice_builtin_reviewed`). A test checks the ids exist.
|
||||
- Accepting the notice: sign-up (`source: 'signup'`), account activation (`'activation'`, the form's
|
||||
terms box) and the after-login prompt (`'prompt'`). `noticePromptPending` is `'first'` for anyone
|
||||
who never accepted (`noticeVersionAccepted` null) and `'updated'` when a significant version came
|
||||
out later. The e2e seed sets `noticeVersionAccepted: 0` so the prompt doesn't cover every test.
|
||||
- The cookie button on the privacy page shows above any section whose id contains "cookie".
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
@@ -192,8 +228,7 @@ a test server (both done 2026-10-28).
|
||||
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (tried on a test server 2026-10-28).
|
||||
|
||||
## Where campuses live (1.43.0)
|
||||
|
||||
@@ -277,6 +312,190 @@ a test server (both done 2026-10-28).
|
||||
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
|
||||
sign-up, cover request, staff take over, QR check-in, roster).
|
||||
|
||||
## Where copying an event lives (1.47.0)
|
||||
|
||||
- The copy is made in the browser: `EventModal` in `app/dashboard/supervisor/events/page.tsx` takes
|
||||
`source` (create mode, `cloning`), fills its state from it (`cloneOptions` leaves out the kids and
|
||||
volunteer tickets and all ids; cancelled sessions dropped; sessions keyed by the source's ids so
|
||||
late joining still points right; sections loaded and mapped onto the copied options; feedback
|
||||
questions without the sent/answered state), and `moveAllDates` shifts every date for "New start".
|
||||
It saves through the normal create path, so **a new field on the event form is copied
|
||||
automatically** if it's initialised from `base`.
|
||||
- What the form doesn't hold is copied by the server afterwards: `utils/eventClone.js`
|
||||
(`copyEventSetup`: allocation sets/groups except `kidsRoomSetId`, costs for admins only since
|
||||
costs are admin-only) via `POST /api/events/:id/copy-from`. Tests: `tests/eventClone.test.js`,
|
||||
e2e "copy an event" in `supervisor.spec.ts`.
|
||||
|
||||
## Where event series live (1.47.1)
|
||||
|
||||
- `backend/src/utils/series.js` — input cleaning, `seriesForEvent`, `nextInSeries` (next public event
|
||||
starting later), `splitEvents`, follow/unfollow, the `series` job (`runAnnounceJob`: public, not
|
||||
started, `seriesAnnouncedAt` null → claimed, then followers filtered with `filterRecipients` as
|
||||
news), `exportFollows`. Tests: `tests/series.test.js`; e2e `series.spec.ts`.
|
||||
- `controllers/seriesController.js` + `routes/seriesRoutes.js` (`/api/series`, campus-independent:
|
||||
the list filters each series' events by scope). Event create/update take `seriesId`; changing it
|
||||
clears `seriesAnnouncedAt` so the new series' followers hear.
|
||||
- `SeriesFollow` is personal data: in `anonymiseUser` and `exportUserData`. No retention rule of its
|
||||
own — follows go when the account is anonymised.
|
||||
- Frontend: `app/dashboard/supervisor/series`, `app/series/[slug]`, `components/events/SeriesLinks.tsx`,
|
||||
`FollowSeriesButton.tsx` (asks the API itself: the page is cached for everyone), the Series box in
|
||||
the event editor. Series pages aren't in the sitemap yet (no public list endpoint).
|
||||
|
||||
## Where season passes live (1.47.3)
|
||||
|
||||
- `backend/src/utils/seasonPass.js` — `splitCents` / `itemShares` (the price over the events by
|
||||
normal price, whole cents), `notOnSale`, `passView`, `cleanPassInput`, `buyPass` (advisory lock per
|
||||
pass, stock checked per item, a registration per event with a tranche at the share),
|
||||
`startPassCheckout` (checkout metadata `{ type: 'season_pass', purchaseId }`), `recordPassPayment`
|
||||
(called from `payments/webhook.js` `recordSucceededPayment`; `allocate` pays each registration what
|
||||
it owes; first Payment carries `externalId`), `notOnSeasonPass` (route guard on options edit and
|
||||
coupons), `exportPurchases`. Tests: `tests/seasonPass.test.js`; e2e in `series.spec.ts`.
|
||||
- `refreshPricingForRegistration` skips pass registrations; `payments/status.js` treats a pass
|
||||
checkout as paid once its purchase is.
|
||||
- Frontend: `components/series/SeasonPassesPanel.tsx` (Event series page),
|
||||
`components/series/SeasonPassOffers.tsx` (series page: buy, "Your passes", Pay now).
|
||||
- Not built yet: buying a pass at the door or by staff, cancelling a whole purchase in one go
|
||||
(staff cancel and refund each event's registration), one combined payment email (each event's
|
||||
payment sends its own).
|
||||
|
||||
## Where outbound webhooks live (1.48.0)
|
||||
|
||||
- **Noticing:** `backend/src/config/db.js` wraps the Prisma client with `$extends` query hooks
|
||||
(registration create/update to paid or cancelled, payment create, ticketUsage create, user create,
|
||||
event create/update to public). **New code gets webhooks for free** as long as it writes through
|
||||
`prisma` (`updateMany` isn't hooked). Hooks only call `notice`; never put slow work there.
|
||||
- `backend/src/utils/webhooks.js` — event types, the in-memory queue (`notice` → `flush` after 1.5 s,
|
||||
which re-reads the record and skips it if it changed back), payload builders (kids/volunteer tickets
|
||||
left out), `send` (claim, SSRF check, sign, 10 s timeout, retry schedule), `runJob` (`webhooks` job:
|
||||
due retries, stuck sends, 30-day prune), `resend`, `sendTest`. Queued events are lost if the
|
||||
process stops in that 1.5 s (no outbox table yet).
|
||||
- `controllers/outboundWebhookController.js` + `routes/outboundWebhookRoutes.js`
|
||||
(`/api/outbound-webhooks`, admin). Operator register entry per endpoint (`operatorId`).
|
||||
- **Adding an event type:** add it to `EVENT_TYPES`, a case in `buildPayload`, a hook in `db.js` if
|
||||
needed, and a line in `dataShared` (operator register text).
|
||||
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
|
||||
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
|
||||
|
||||
## Demo data (1.48.2)
|
||||
|
||||
- `backend/scripts/set-demo.js` builds everything in memory and writes it with `createMany` in one
|
||||
transaction (so no webhooks fire). `PROFILES` says which of the six upcoming events, which past
|
||||
events (by title) and which extras (`series`, `courses`, `conference`, `kids`, `volunteers`) each
|
||||
profile gets, and whether campuses are on. **A new feature that's worth demoing** gets a builder
|
||||
and an extra; add its tables to `INSERT_ORDER` (parents first).
|
||||
- Keep jobs quiet: set anything a job would act on as already done (`seriesAnnouncedAt`,
|
||||
`remindedAt`, certificates issued). Demo people use @example.com and no phone numbers.
|
||||
- `clearData` truncates every table except settings and users, but deletes (not truncates) tables
|
||||
that `User` points at — TRUNCATE CASCADE would empty `User` with them.
|
||||
- Tried by seeding all four into a throwaway database and calling the API (events, series page and
|
||||
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
|
||||
scoping).
|
||||
|
||||
## Where the dashboard and palette live (1.49.0)
|
||||
|
||||
- `backend/src/utils/dashboard.js` — `todaysEvents`, `moneyIn`, `pendingItems` (each item counted
|
||||
separately, failures logged and skipped; role-only items for admins), `search` (+ `amountOf`).
|
||||
Everything goes through `eventWhere(scopeFor(req))`. Routes: `routes/dashboardRoutes.js`.
|
||||
**A new "something needs doing" count** goes in `pendingItems` (with its link and the `order`).
|
||||
- Frontend: `components/dashboard/TodayPanel.tsx`, `components/shared/CommandPalette.tsx` (mounted in
|
||||
`app/dashboard/layout.tsx`; `CommandPaletteButton` on the dashboards), `lib/commandPages.ts` —
|
||||
**a new page or setting tab should be added there** with its roles.
|
||||
- Deep links read `window.location.search` once on load (no Suspense needed).
|
||||
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
|
||||
|
||||
## Where bulk actions live (1.49.1)
|
||||
|
||||
- `backend/src/utils/bulkRegistrations.js` — `runBulk` checks the input (≤ 500, licence read-only for
|
||||
payments and moves) and runs one action per registration, collecting a result for each. Most
|
||||
actions **call the existing Express handlers in-process** (`call(handler, req)`, with the original
|
||||
request's user, headers and socket so audits keep the IP): `createPayment`, `cancelRegistration`,
|
||||
`allocationController.assign`, `emailEventAttendees` / `whatsappEventAttendees` (new
|
||||
`filter.registrationIds`). **Change a rule in those handlers and bulk follows.**
|
||||
- Moving tickets is its own code (`moveOption`): the edit handler refuses to remove issued tickets,
|
||||
so the lines are re-pointed in place (tickets and QR codes kept), repriced with `resolveOptionPrice`,
|
||||
attendees moved, then `updateRegistrationStatus`.
|
||||
- Frontend: `components/admin/RegistrationBulkBar.tsx` on `app/dashboard/admin/registrations`. Export
|
||||
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
|
||||
`bulk.spec.ts`.
|
||||
|
||||
## Saved filters (1.49.2)
|
||||
|
||||
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
|
||||
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
|
||||
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
|
||||
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
|
||||
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
|
||||
- e2e `saved-filters.spec.ts` (deletes what it saves).
|
||||
|
||||
## Accessibility (1.50.0)
|
||||
|
||||
- **Popups:** use `components/shared/ModalOverlay.tsx` (`labelledBy` = the heading's id, `onClose`,
|
||||
`closeOnBackdrop`) instead of a bare `fixed inset-0` div — it gives the dialog role, focus trap,
|
||||
Escape and focus return. Radix `ui/dialog` is fine too.
|
||||
- **Fields:** tie every visible `<label>` to its control (`htmlFor`/`id`, or wrap the control); in
|
||||
lists use an id with the row key, or `aria-label`. Icon-only buttons need `aria-label`.
|
||||
- **Text colour:** `text-gray-500` is the lightest grey for text on white (`gray-400` fails 4.5:1).
|
||||
Links inside a `<p>`/`<label>` are underlined by `globals.css`.
|
||||
- **Tests:** `e2e/accessibility.spec.ts` — add new public/member pages to it. It checks with axe's
|
||||
WCAG 2.0–2.2 A/AA tags; the app has no dark mode, so only the light theme is checked.
|
||||
- **Not covered yet:** the staff, supervisor and admin screens (scanner, at-the-door, settings …)
|
||||
weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made
|
||||
popups. Do them when those pages are next worked on.
|
||||
|
||||
## Long lists (1.50.1)
|
||||
|
||||
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
|
||||
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
|
||||
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
|
||||
payments, audit log, consent/access logs, data requests, My payments.
|
||||
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## TypeScript and API v2 (1.51.0)
|
||||
|
||||
- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs
|
||||
`dist/app.js` when `dist/build.json` has this version; otherwise tsx compiles on the fly (with a
|
||||
warning). tsx and typescript are runtime dependencies for that fallback. `npm run dev` and e2e
|
||||
use tsx, so they never pick up a stale `dist/`. The server is `src/app.js`.
|
||||
- **Paths:** `dist/` mirrors `src/`, so `path.join(__dirname, '..', '..', 'public')` works from
|
||||
both. Non-code files in `src/` aren't copied (the favicon is read from `src/`).
|
||||
- **New code in TypeScript.** Convert a JS module when a release touches it; until then import it
|
||||
through `src/v2/legacy.ts` with a typed signature. TypeScript infers loose types from JS (the
|
||||
Prisma client especially), so v2 code takes `prisma` from there, typed as `PrismaClient`.
|
||||
- **A v2 endpoint** is `defineRoute({ method, path, tag, summary, access, params?, query?, body?,
|
||||
response, handler })` in `src/v2/resources/<area>.ts`, imported in `src/v2/index.ts`. Lists use
|
||||
`pageQuery`, `paged()` and `pageOf()`; single items `single()`. Dates go out as ISO strings
|
||||
(`iso()`), so response schemas use `isoDate`. Throw `ApiError(code, message)` or `notFound()`.
|
||||
The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a
|
||||
log line (tests catch it). Staff/supervisor routes must declare `campusScope` (start-up fails
|
||||
otherwise).
|
||||
- **Services** (`src/services/*.ts`, 1.51.1): business logic both API versions call. Throw
|
||||
`ServiceError(status, message)` (`fail()`); v1 controllers keep their old answers (mostly 400
|
||||
for any error), v2 maps the status to its codes. Helper errors without a status count as 400,
|
||||
like v1. JS helpers are required with a typed signature at the top of each service.
|
||||
- **Frontend v2 calls:** `apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken })`
|
||||
(`frontend/src/lib/apiV2.ts`). After adding or changing a v2 route run `npm run api:types` in the
|
||||
repo root and commit `frontend/src/lib/api-v2/` (`tests/v2.test.ts` fails while it's stale).
|
||||
- Not yet v2-shaped: the global middleware in front of every route (rate limit, impersonation
|
||||
read-only guard, licence read-only) still answers `{ message }`. Do that with 1.51.2.
|
||||
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
|
||||
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
|
||||
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
|
||||
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
|
||||
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
|
||||
`pickEvent(page, title)` from `e2e/fixtures.ts`.
|
||||
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
|
||||
a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
|
||||
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
|
||||
shape.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -287,8 +506,6 @@ a test server (both done 2026-10-28).
|
||||
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
|
||||
- There's no log of individual messages sent, so "Download my data" can't include "messages
|
||||
sent to you" yet.
|
||||
- The audit-log page's action filter list was already missing newer actions before 1.38.0
|
||||
(only the three privacy ones were added).
|
||||
|
||||
- At the door, per-session tickets are booked on new registrations only; editing an
|
||||
existing registration leaves its booked sessions as they are (the backend refuses
|
||||
|
||||
+76
-20
@@ -34,7 +34,13 @@ Wallet passes are removed from scope (2026-10-28), so everything after volunteer
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
**Next: 1.47.0 Event series and cloning.**
|
||||
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations** and 1.51.1 **v2 registrations and payments** shipped. **Next: 1.51.2 v2 check-in and
|
||||
accounts, then 1.51.3.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -56,7 +62,7 @@ assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional
|
||||
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
|
||||
@@ -460,15 +466,25 @@ Plan who serves where, for an event or each session. (Shipped in 1.46.0 — deci
|
||||
|
||||
## 6. Event series and cloning
|
||||
|
||||
- **Clone event:** copy an event with its options, variants, early-bird tiers,
|
||||
forms, consent text, feedback form, cost template, allocation groups and
|
||||
sessions (dates shifted to the new start). No registrations.
|
||||
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Four releases, one per part (2026-10-28): **1.47.0** clone, **1.47.1** series, **1.47.2**
|
||||
year-on-year reports, **1.47.3** season pass.
|
||||
|
||||
- **Clone event (1.47.0 ✅):** "Copy" opens the **New event popup filled in** from the source, so
|
||||
everything can be changed before saving: options, variants, early-bird tiers, form, consent
|
||||
text, feedback form, sections, notification recipients, certificates, reminders, allocation
|
||||
groups, cost template (admins) and sessions. A "New start" moves every date with it. Of the
|
||||
newer features only the **campus** is copied — not kids check-in or volunteer roles. No
|
||||
registrations, **no coupons**.
|
||||
- **Series (1.47.1 ✅):** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Breakfast) on one series page, with "Next in this series" and a series
|
||||
follow/notify option.
|
||||
- **Year-on-year reports:** registrations, income and attendance compared
|
||||
follow/notify option. **A series can span several campuses.** Following is **News &
|
||||
announcements** (opted-in only, unsubscribe link); followers get one email when a new event in the
|
||||
series goes live.
|
||||
- **Year-on-year reports (1.47.2 ✅):** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass:** a bundle across events in a series (extends event bundles).
|
||||
- **Season pass (1.47.3 ✅):** a bundle across events in a series. One checkout registers the buyer at
|
||||
every event on the pass; the supervisor picks the events (and ticket at each); the price is split
|
||||
in proportion to the events' normal prices for each event's cashup (2026-10-28).
|
||||
|
||||
---
|
||||
|
||||
@@ -489,7 +505,9 @@ Plan who serves where, for an event or each session. (Shipped in 1.46.0 — deci
|
||||
|
||||
## 8. Outbound webhooks
|
||||
|
||||
Let other systems react to what happens in Cross Events.
|
||||
Let other systems react to what happens in Cross Events. (Shipped in 1.48.0: Site settings →
|
||||
Webhooks; events noticed through a Prisma client extension; each endpoint added to the operator
|
||||
register; deliveries kept 30 days.)
|
||||
|
||||
- Admin adds endpoints and picks events: `registration.created`,
|
||||
`registration.paid`, `registration.cancelled`, `payment.refunded`,
|
||||
@@ -503,6 +521,9 @@ Let other systems react to what happens in Cross Events.
|
||||
|
||||
## 9. Admin dashboard, command palette, bulk actions
|
||||
|
||||
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
|
||||
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
|
||||
|
||||
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
|
||||
far, money in (today/week), pending items (unreconciled payments, consent
|
||||
outstanding, waitlist offers, data requests), alerts.
|
||||
@@ -516,6 +537,8 @@ Let other systems react to what happens in Cross Events.
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
@@ -532,6 +555,9 @@ Let other systems react to what happens in Cross Events.
|
||||
fields.
|
||||
- Automated `axe` checks in the Playwright suite so it doesn't regress.
|
||||
|
||||
**Shipped in 1.50.0** for the pages above. The app has no dark mode, so only the light theme is
|
||||
checked. Staff/supervisor/admin screens are left for when they're next worked on (see HANDOVER).
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-campus (one install per church)
|
||||
@@ -854,11 +880,34 @@ an install (one leaked install would leak it for everyone), so:
|
||||
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
|
||||
the result. Still **install → Hub only** — the Hub never reaches into an install.
|
||||
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
|
||||
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
|
||||
Joshua sets in the Hub × **(1 + markup)**. Usage is kept per install per month, as both the
|
||||
real cost and the charged (marked-up) amount.
|
||||
- **Markup** (decided 2026-10-29): one percentage set in the Hub (a default for every licence,
|
||||
with an override per licence), e.g. 40%. It does two jobs: it covers Joshua if a month's real
|
||||
usage runs a little over (exchange-rate moves, a last call that costs more than expected), and
|
||||
it is his profit on AI. Churches only ever see the charged amount in rand. The real cost, the
|
||||
markup and the profit are shown only in the Hub.
|
||||
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
|
||||
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
|
||||
install's admins get a warning email; at 100% the helpers are switched off until the next
|
||||
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
|
||||
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. The cap is
|
||||
counted in **charged** rand, so the real spend can never be more than cap ÷ (1 + markup).
|
||||
- **Hard stop, checked before each call:** the Hub refuses a call when *charged so far + the
|
||||
worst case for this call* (its prompt plus the helper's `max_tokens`, priced with markup) would
|
||||
pass the cap. A month can't run over by more than the markup already covers, even with
|
||||
several people clicking at once (the check and the usage record happen in one transaction).
|
||||
At 80% the install's admins get a warning email. Optional per-user daily limit against
|
||||
runaway use.
|
||||
- **The buttons follow the cap** (decided 2026-10-29):
|
||||
- **Cap R0** (or AI off on the licence, or the install not connected to the Hub): the AI
|
||||
buttons are **not shown at all**, anywhere.
|
||||
- **Cap above R0, room left:** the buttons show and work.
|
||||
- **Cap reached:** the buttons stay where they are but are **disabled**, with a tooltip like
|
||||
"This month's AI allowance is used up — it renews on 1 December". They come back by
|
||||
themselves next month, or as soon as Joshua raises the cap.
|
||||
- The install learns this from `GET /api/v2/ai/status` → `{ enabled, capReached, renewsOn,
|
||||
helpers }`. The answer is cached briefly from the Hub and refreshed after every call.
|
||||
Every AI call also returns the new status, so the buttons disable straight after the call
|
||||
that used up the cap. The backend checks again on each request, so a stale page can't get
|
||||
past the cap.
|
||||
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
|
||||
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
|
||||
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
|
||||
@@ -879,7 +928,12 @@ an install (one leaked install would leak it for everyone), so:
|
||||
|
||||
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
|
||||
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
|
||||
has no AI or the Hub isn't connected.
|
||||
has no AI or the Hub isn't connected. The AI endpoints exist **only in `/api/v2`**
|
||||
(`/api/v2/ai/status`, `/api/v2/ai/<helper>`), built on 1.51's schemas and error format.
|
||||
The frontend has one `useAiStatus()` hook and an `<AiButton>` that hides, enables or
|
||||
disables itself from it.
|
||||
- Hub: real cost and charged amount per call; the markup default and per-licence override;
|
||||
a per-install month view with **cost, charged and profit**; the cap check before each call.
|
||||
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
|
||||
usage view for Joshua (alongside the existing install detail page).
|
||||
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
|
||||
@@ -922,8 +976,7 @@ each confirmed action in the audit log.
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
@@ -953,9 +1006,9 @@ income justify the hardware:
|
||||
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 – 1.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2, 1.51.3 | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -993,6 +1046,7 @@ feature.
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
@@ -1006,8 +1060,10 @@ feature.
|
||||
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
|
||||
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
|
||||
|
||||
## Still open
|
||||
|
||||
+50
-4
@@ -55,7 +55,9 @@ POST /api/installs/register
|
||||
"installSecret": "…", // shown once; the install stores it encrypted
|
||||
"licence": "<licence token>",
|
||||
"heartbeatIntervalMinutes": 15,
|
||||
"latestVersion": "1.41.0"
|
||||
"latestVersion": "1.41.0",
|
||||
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
|
||||
"notices": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,9 +103,30 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||||
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
|
||||
}
|
||||
}
|
||||
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
|
||||
→ 200 {
|
||||
"licence": "<fresh licence token>",
|
||||
"latestVersion": "1.42.0",
|
||||
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
|
||||
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
|
||||
],
|
||||
"notices": [ // Hub 1.4.0+: the provider's banners for this install
|
||||
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
|
||||
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
|
||||
],
|
||||
"serverTime": "…"
|
||||
}
|
||||
```
|
||||
|
||||
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
|
||||
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
|
||||
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
|
||||
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
|
||||
admins put them off. The highest release is `latestVersion`.
|
||||
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
|
||||
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
|
||||
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
|
||||
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
|
||||
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
|
||||
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
|
||||
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
|
||||
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
|
||||
@@ -121,17 +144,40 @@ Signed exactly like the heartbeat.
|
||||
POST /api/installs/update-request
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1, "version": "1.41.2" }
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false }
|
||||
→ 200 { …, "alreadyRequested": true } // a request is already open: not emailed again
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false, "changed": false }
|
||||
→ 200 { …, "alreadyRequested": true, "changed": false } // a request for the latest is already open: not emailed again
|
||||
→ 200 { …, "alreadyRequested": true, "changed": true, "previousVersion": "1.42.0" }
|
||||
// the open request was for an older version: it now asks for the latest
|
||||
```
|
||||
|
||||
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
|
||||
alert address once. It is resolved when a heartbeat reports a newer version than before.
|
||||
- **Changing a request (Hub 1.5.0):** when a request is open for a version that has since been
|
||||
overtaken, asking again moves it on to the latest version — the alert says "latest is 1.47.3
|
||||
(changed from 1.47.0)" and the alert address is emailed once about the change. `version` is
|
||||
always the version now asked for. A Hub before 1.5.0 doesn't send `changed`; installs then
|
||||
withdraw the request and ask again (two emails, same result).
|
||||
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
|
||||
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
|
||||
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
|
||||
- No personal information: not who pressed the button (the Hub already has the admin contact).
|
||||
|
||||
### Withdrawing it
|
||||
|
||||
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
|
||||
|
||||
```
|
||||
POST /api/installs/update-request/cancel
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1 }
|
||||
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
|
||||
→ 200 { "cancelled": false } // there was no open request
|
||||
```
|
||||
|
||||
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
|
||||
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
|
||||
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
|
||||
|
||||
## 4. Licence token
|
||||
|
||||
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
|
||||
|
||||
// WCAG 2.2 AA checks (axe-core) on the pages visitors and members use, so the
|
||||
// accessibility pass (1.50.0) doesn't regress.
|
||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
|
||||
async function check(page: Page, label: string) {
|
||||
// Let animations and late-loading data settle so colours are measured as shown.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(300);
|
||||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||
const summary = results.violations.map((v) => ({
|
||||
id: v.id,
|
||||
help: v.help,
|
||||
nodes: v.nodes.map((n) => `${n.target.join(" ")} — ${n.failureSummary?.split("\n").slice(0, 2).join(" ")}`),
|
||||
}));
|
||||
expect.soft(summary, `${label}: accessibility violations`).toEqual([]);
|
||||
}
|
||||
|
||||
async function visit(page: Page, url: string, label: string, ready?: () => Promise<void>) {
|
||||
await page.goto(url);
|
||||
if (ready) await ready();
|
||||
await check(page, label);
|
||||
}
|
||||
|
||||
test.describe("accessibility (WCAG 2.2 AA)", () => {
|
||||
test("public pages", async ({ page }) => {
|
||||
await visit(page, "/", "home");
|
||||
await visit(page, "/events", "events", () => expect(page.getByText("E2E Free Evening")).toBeVisible());
|
||||
await visit(page, `/events/${seed().events.course}`, "event page", () =>
|
||||
expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible(),
|
||||
);
|
||||
await visit(page, `/events/${seed().events.dropIn}`, "drop-in event page");
|
||||
await visit(page, "/contact", "contact");
|
||||
await visit(page, "/legal/privacy", "privacy notice");
|
||||
await visit(page, "/legal/terms", "terms");
|
||||
});
|
||||
|
||||
test("sign in, sign up and forgot password, with errors", async ({ page }) => {
|
||||
await visit(page, "/login", "login");
|
||||
await page.getByLabel("Email or phone").fill(seed().users.user.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill("not-the-password");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await check(page, "login with an error");
|
||||
await visit(page, "/register", "sign up");
|
||||
await visit(page, "/forgot-password", "forgot password");
|
||||
});
|
||||
|
||||
test("registration form", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await visit(page, `/register/${seed().events.paid}`, "registration", async () => {
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
});
|
||||
await visit(page, `/register/${seed().events.dropIn}`, "registration (pick sessions)");
|
||||
});
|
||||
|
||||
test("My Events and account pages", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await visit(page, "/dashboard/user", "My Events");
|
||||
await visit(page, "/dashboard/user/profile", "profile");
|
||||
await visit(page, "/dashboard/user/privacy", "privacy centre");
|
||||
await visit(page, "/dashboard/user/payments", "payments");
|
||||
await visit(page, "/dashboard/user/kids", "kids");
|
||||
await visit(page, "/dashboard/user/serving", "serving");
|
||||
});
|
||||
|
||||
test("self-service kiosk", async ({ page }) => {
|
||||
await visit(page, "/self-service", "kiosk sign-in");
|
||||
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
await check(page, "kiosk event picker");
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
await check(page, "kiosk form");
|
||||
|
||||
// "Create an account" is a switch you can reach and flip with the keyboard.
|
||||
const create = page.getByRole("switch", { name: /Create an account/ });
|
||||
await create.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(create).toHaveAttribute("aria-checked", "true");
|
||||
await expect(page.getByLabel("Choose a password")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("keyboard", () => {
|
||||
test("the first Tab reaches “Skip to main content”, which moves focus to the page content", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
const skip = page.getByRole("link", { name: "Skip to main content" });
|
||||
await expect(skip).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("main").first()).toBeFocused();
|
||||
});
|
||||
|
||||
test("a popup keeps focus inside, closes with Escape and hands focus back", async ({ page }) => {
|
||||
await page.goto("/self-service");
|
||||
await page.getByLabel("Email").fill(seed().users.supervisor.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
const opener = page.getByRole("button", { name: "Change Event" });
|
||||
await opener.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const dialog = page.getByRole("dialog", { name: "Change Event" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByLabel("Password", { exact: true })).toBeFocused();
|
||||
// Tab doesn't leave the popup.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press("Tab");
|
||||
expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(opener).toBeFocused();
|
||||
});
|
||||
|
||||
test("ticket quantities can be changed with the keyboard and are announced by name", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.paid}`);
|
||||
const add = page.getByRole("button", { name: /^Add one / }).first();
|
||||
await add.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Remove one / }).first()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, seed, API } from "./fixtures";
|
||||
|
||||
// /api/v2 (1.51.0) against the real server and database: paged public events, the v2 error
|
||||
// shape, the signed-in person, and the OpenAPI reference at /docs/v2.
|
||||
test.describe("API v2", () => {
|
||||
test("public events come a page at a time; errors have a code", async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/v2/events?limit=2`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.meta).toMatchObject({ page: 1, limit: 2 });
|
||||
expect(body.meta.total).toBeGreaterThan(2);
|
||||
|
||||
const search = await (await request.get(`${API}/api/v2/events?q=free%20evening`)).json();
|
||||
expect(search.data.map((e: { title: string }) => e.title)).toEqual(["E2E Free Evening"]);
|
||||
|
||||
const one = await (await request.get(`${API}/api/v2/events/${seed().events.paid}`)).json();
|
||||
expect(one.data.ticketOptions.length).toBeGreaterThan(0);
|
||||
|
||||
const bad = await request.get(`${API}/api/v2/events?limit=1000`);
|
||||
expect(bad.status()).toBe(400);
|
||||
expect((await bad.json()).error).toMatchObject({ code: "VALIDATION_FAILED", fields: { limit: expect.any(String) } });
|
||||
|
||||
const missing = await request.get(`${API}/api/v2/events/00000000-0000-4000-8000-000000000000`);
|
||||
expect((await missing.json()).error.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
test("me and my registrations need a token", async ({ request }) => {
|
||||
expect((await request.get(`${API}/api/v2/me`)).status()).toBe(401);
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const me = await (await request.get(`${API}/api/v2/me`, { headers })).json();
|
||||
expect(me.data).toMatchObject({ id: seed().users.user.id, role: "user" });
|
||||
const regs = await (await request.get(`${API}/api/v2/me/registrations?past=true`, { headers })).json();
|
||||
expect(regs.meta).toMatchObject({ page: 1, limit: 25 });
|
||||
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
|
||||
});
|
||||
|
||||
test("register for a free event, then cancel it", async ({ request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
|
||||
const made = await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
|
||||
expect(made.status()).toBe(201);
|
||||
const reg = (await made.json()).data;
|
||||
expect(reg).toMatchObject({ status: "paid", balance: 0, event: { id: event.id } });
|
||||
|
||||
const someoneElse = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: { Authorization: `Bearer ${seed().users.user.token}` } });
|
||||
expect((await someoneElse.json()).error.code).toBe("NOT_FOUND");
|
||||
|
||||
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
|
||||
expect((await cancelled.json()).data.status).toBe("cancelled");
|
||||
const again = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
|
||||
expect((await again.json()).error).toMatchObject({ code: "VALIDATION_FAILED", message: "Registration is already cancelled" });
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
|
||||
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
|
||||
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
|
||||
// The token isn't left in the address bar.
|
||||
expect(page.url()).not.toContain("token=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
|
||||
// Bulk actions on the registrations list (1.49.1): tick several, mark them paid, move them to
|
||||
// another ticket, cancel — each through the same rules as one at a time.
|
||||
type Ev = { id: string; eventOptions?: { id: string; name: string; isMainTicket?: boolean }[] };
|
||||
type Reg = { id: string; status: string; registrationOptions?: { eventOptionId: string; quantity: number }[]; payments?: { amount: number; method: string }[] };
|
||||
|
||||
test.describe.serial("bulk actions on registrations", () => {
|
||||
let event: Ev;
|
||||
let main: string;
|
||||
let vip: string;
|
||||
const regs: string[] = [];
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
const start = new Date(Date.now() + 20 * 864e5);
|
||||
event = await api<Ev>(page, "admin", "POST", "/api/events", {
|
||||
title: "E2E Bulk Night", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3 * 3600e3).toISOString(),
|
||||
price: 100, location: "Church hall",
|
||||
});
|
||||
const full = await api<Ev>(page, "admin", "GET", `/api/events/${event.id}`);
|
||||
main = full.eventOptions!.find(o => o.isMainTicket)!.id;
|
||||
vip = (await api<{ id: string }>(page, "admin", "POST", `/api/events/${event.id}/options`, { name: "VIP", price: 150, isMainTicket: true })).id;
|
||||
for (const role of ["staff", "supervisor", "user"] as const) {
|
||||
regs.push((await api<{ id: string }>(page, role, "POST", "/api/registrations", { eventId: event.id, options: [{ eventOptionId: main, quantity: 1 }] })).id);
|
||||
}
|
||||
await page.close();
|
||||
});
|
||||
|
||||
const openList = async (page: import("@playwright/test").Page) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/dashboard/admin/registrations?event=${event.id}`);
|
||||
await expect(page.getByText("3 of", { exact: false })).toBeVisible();
|
||||
};
|
||||
|
||||
test("tick all and mark them paid in cash", async ({ page }) => {
|
||||
await openList(page);
|
||||
await page.getByLabel("Select all shown").check();
|
||||
const bar = page.getByTestId("bulk-bar");
|
||||
await expect(bar).toContainText("3 selected");
|
||||
await bar.getByLabel("Bulk action").selectOption("markPaid");
|
||||
await bar.getByLabel("Paid by").selectOption("cash");
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar.getByRole("button", { name: /Mark paid/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("3 done");
|
||||
|
||||
for (const id of regs) {
|
||||
const r = await api<Reg>(page, "admin", "GET", `/api/registrations/${id}`);
|
||||
expect(r.status).toBe("paid");
|
||||
expect(r.payments?.map(p => [p.amount, p.method])).toEqual([[100, "cash"]]);
|
||||
}
|
||||
});
|
||||
|
||||
test("move two to the VIP ticket; cancel the third", async ({ page }) => {
|
||||
await openList(page);
|
||||
const rows = page.locator("li").filter({ has: page.getByRole("checkbox", { name: /^Select / }) });
|
||||
await rows.nth(0).getByRole("checkbox").check();
|
||||
await rows.nth(1).getByRole("checkbox").check();
|
||||
const bar = page.getByTestId("bulk-bar");
|
||||
await bar.getByLabel("Bulk action").selectOption("moveOption");
|
||||
await bar.getByLabel("Move from").selectOption(main);
|
||||
await bar.getByLabel("Move to").selectOption(vip);
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar.getByRole("button", { name: /Move to another ticket/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("2 done");
|
||||
|
||||
const moved = await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)));
|
||||
const onVip = moved.filter(r => r.registrationOptions?.some(o => o.eventOptionId === vip));
|
||||
expect(onVip).toHaveLength(2);
|
||||
// R150 now, R100 paid: they owe the difference.
|
||||
expect(onVip.every(r => r.status === "partial_paid")).toBe(true);
|
||||
|
||||
await bar.getByRole("button", { name: "Clear selection" }).click();
|
||||
const left = moved.find(r => !r.registrationOptions?.some(o => o.eventOptionId === vip))!;
|
||||
await page.getByRole("checkbox", { name: /^Select / }).nth(2).check();
|
||||
const selectedId = (await api<Reg>(page, "admin", "GET", `/api/registrations/${left.id}`)).id;
|
||||
expect(selectedId).toBe(left.id);
|
||||
const bar2 = page.getByTestId("bulk-bar");
|
||||
await bar2.getByLabel("Bulk action").selectOption("cancel");
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar2.getByRole("button", { name: /Cancel — 1/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("1 done");
|
||||
const cancelled = (await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)))).filter(r => r.status === "cancelled");
|
||||
expect(cancelled).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, loginAs } from "./fixtures";
|
||||
|
||||
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
|
||||
test.describe("dashboard and command palette", () => {
|
||||
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin");
|
||||
const today = page.getByTestId("today-panel");
|
||||
await expect(today).toBeVisible();
|
||||
// Seeded on today: the hidden kids church and serving Sunday.
|
||||
await expect(today.getByTestId("today-events")).toContainText("E2E Serving Sunday");
|
||||
await expect(today.getByText("Waiting for you")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Ctrl+K finds an event and opens it", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor");
|
||||
await expect(page.getByTestId("today-panel")).toBeVisible();
|
||||
await page.keyboard.press("Control+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("combobox", { name: "Search" }).fill("Paid Conference");
|
||||
await expect(dialog.getByRole("option", { name: /E2E Paid Conference/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/dashboard\/supervisor\/events/);
|
||||
await expect(page.getByRole("heading", { name: "Edit Event" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Paid Conference");
|
||||
});
|
||||
|
||||
test("the palette's actions and pages follow the role", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor");
|
||||
await page.getByRole("button", { name: /Search or jump to/ }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await dialog.getByRole("combobox", { name: "Search" }).fill("new event");
|
||||
await dialog.getByRole("option", { name: "New event" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
|
||||
|
||||
// Staff don't get admin pages.
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff");
|
||||
await page.keyboard.press("Control+k");
|
||||
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
|
||||
await expect(staffDialog.getByRole("option", { name: "Users" })).toHaveCount(0);
|
||||
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
|
||||
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
|
||||
// 1.50.2: event dropdowns are a searchable picker — upcoming events first, past ones a click
|
||||
// away, and typing searches them all.
|
||||
test.describe("event picker", () => {
|
||||
test("opens on upcoming events; past ones are behind a button or found by searching", async ({ page }) => {
|
||||
const start = new Date(Date.now() - 40 * 864e5);
|
||||
await api(page, "supervisor", "POST", "/api/events", {
|
||||
title: "E2E Picker Old Gathering", description: "A past event for the picker test.", price: 0, location: "1 Test Street, Pretoria",
|
||||
startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 3600e3).toISOString(),
|
||||
});
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/email-attendees");
|
||||
await page.getByRole("button", { name: /^Event: Select an event/ }).click();
|
||||
const list = page.getByRole("listbox", { name: "Events" });
|
||||
await expect(list.getByRole("option", { name: /E2E Free Evening/ })).toBeVisible();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toHaveCount(0);
|
||||
// The open picker passes the WCAG 2.2 AA checks.
|
||||
const axe = await new AxeBuilder({ page }).include("[data-testid=event-picker]").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: /^Show past events/ }).click();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hide past events" }).click();
|
||||
|
||||
// Searching finds past events too; the keyboard chooses.
|
||||
const search = page.getByRole("combobox", { name: "Search events" });
|
||||
await search.fill("old gathering");
|
||||
await expect(list.getByRole("option")).toHaveCount(1);
|
||||
await search.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeFocused();
|
||||
|
||||
// Escape closes without changing anything.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(list).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(list).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the reports event list can be searched", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/supervisor/reports");
|
||||
await page.getByRole("button", { name: /^Events:/ }).click();
|
||||
await page.getByRole("button", { name: "Clear", exact: true }).click();
|
||||
await page.getByLabel("Search events").fill("alpha");
|
||||
await page.getByRole("button", { name: /^Select these 1$/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Events: E2E Alpha Course" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export type Seed = {
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
noticeFirstUser: { id: string; token: string };
|
||||
retention: { toWarn: string; toAnonymise: string };
|
||||
};
|
||||
|
||||
@@ -50,4 +51,12 @@ export async function api<T = unknown>(page: Page, role: Role, method: string, u
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
/** Chooses an event in an EventPicker (the first on the page, or the one labelled `label`) by searching its title. */
|
||||
export async function pickEvent(page: Page, title: string, label?: string) {
|
||||
const button = label ? page.getByRole("button", { name: new RegExp(`^${label}:`) }) : page.getByTestId("event-picker").first().getByRole("button").first();
|
||||
await button.click();
|
||||
await page.getByRole("combobox", { name: /^Search/ }).fill(title);
|
||||
await page.getByRole("option", { name: new RegExp(title) }).first().click();
|
||||
}
|
||||
|
||||
export { expect };
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
|
||||
|
||||
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
|
||||
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
|
||||
@@ -7,7 +7,7 @@ test.describe.serial("kids check-in", () => {
|
||||
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/kids");
|
||||
await page.getByRole("combobox").first().selectOption(seed().events.kids);
|
||||
await pickEvent(page, "E2E Kids Church");
|
||||
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
|
||||
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
// 1.50.1: long lists load a page (or just what's needed) at a time, with search and filters.
|
||||
test.describe("long lists", () => {
|
||||
test("Payments: today, recent and a person's payments for a refund", async ({ page }) => {
|
||||
// A donation recorded today for the seeded user (on an event no other test totals up).
|
||||
await api(page, "supervisor", "POST", "/api/payments", {
|
||||
amount: 77, method: "cash", isDonation: true, eventId: seed().events.conference, userId: seed().users.user.id,
|
||||
});
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/payments");
|
||||
await expect(page.getByText("R 77.00").first()).toBeVisible();
|
||||
|
||||
// Refund: picking the person loads their payments.
|
||||
await page.locator("label", { hasText: "Refund" }).first().click();
|
||||
await page.getByPlaceholder("Search by name, email or phone…").last().fill(seed().users.user.name);
|
||||
await page.getByRole("button", { name: new RegExp(seed().users.user.name) }).first().click();
|
||||
await expect(page.locator("option", { hasText: "R 77.00" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Payments API: narrowing filters", async ({ page }) => {
|
||||
const userId = seed().users.user.id;
|
||||
const mine = await api<{ data: { userId: string }[] }>(page, "supervisor", "GET", `/api/payments?userId=${userId}&limit=200`);
|
||||
expect(mine.data.length).toBeGreaterThan(0);
|
||||
expect(mine.data.every((p) => p.userId === userId)).toBe(true);
|
||||
|
||||
const unpaid = await api<{ status: string }[]>(page, "supervisor", "GET", "/api/registrations?unpaid=1");
|
||||
expect(unpaid.every((r) => ["pending", "confirmed", "partial_paid"].includes(r.status))).toBe(true);
|
||||
});
|
||||
|
||||
test("Audit log: search and the full list of actions", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/audit-log");
|
||||
const action = page.getByLabel("Action");
|
||||
await expect(action.locator("option", { hasText: "Bulk action on registrations" })).toHaveCount(1);
|
||||
await expect(action.locator("option", { hasText: "Webhook added, changed or deleted" })).toHaveCount(1);
|
||||
|
||||
await page.getByLabel("Search").fill("nobody-matches-this-xyz");
|
||||
await expect(page.getByText("No matching audit entries.")).toBeVisible();
|
||||
await expect(page.getByText(/0 actions match the filters/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear filters" }).click();
|
||||
await expect(page.getByLabel("Search")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("Data requests: search", async ({ page }) => {
|
||||
const res = await api<{ requests: unknown[]; total: number }>(page, "admin", "GET", "/api/privacy/requests?status=closed&search=nobody-matches-this-xyz");
|
||||
expect(res.requests).toEqual([]);
|
||||
expect(res.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
|
||||
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
|
||||
@@ -8,7 +8,7 @@ test.describe("pick sessions", () => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await expect(picker.getByText("Full")).toBeVisible();
|
||||
@@ -50,7 +50,7 @@ test.describe("pick sessions", () => {
|
||||
test("join the waitlist for a full class", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("button", { name: "Join waitlist" }).click();
|
||||
@@ -67,12 +67,11 @@ test.describe("pick sessions", () => {
|
||||
test("at the door: book a class for a walk-in", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/at-the-door");
|
||||
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
|
||||
// The page picks a default event once its list loads; choose ours after that.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("select").first().selectOption(value!);
|
||||
await pickEvent(page, "E2E Drop-in Class");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("select").first()).toHaveValue(value!);
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Drop-in Class/ })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
|
||||
await page.getByText('New attendee "Door Walk In"…').click();
|
||||
@@ -80,7 +79,7 @@ test.describe("pick sessions", () => {
|
||||
await page.getByRole("button", { name: "Next — Choose Items" }).click();
|
||||
|
||||
await expect(page.getByText("R 50.00 per session")).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await expect(page.getByText("Total: R 50.00")).toBeVisible();
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
// A version that needs accepting is published below; accept it for every seeded account
|
||||
// afterwards so later test files don't meet the prompt.
|
||||
test.afterAll(async ({ request }) => {
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token, seed().noticeFirstUser.token];
|
||||
for (const token of tokens) {
|
||||
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
@@ -55,6 +55,52 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
|
||||
});
|
||||
|
||||
test("after publishing its own version, the editor points out new built-in text", async ({ page }) => {
|
||||
// The published version came from the built-in text; put an older wording back in one changed
|
||||
// section so it shows as having new text.
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
const sections = draft.sections.map(s => (s.id === "changes" ? { ...s, body: "We may update this notice from time to time." } : s));
|
||||
await api(page, "admin", "PUT", "/api/privacy/notice/draft", { sections });
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy?tab=notice");
|
||||
const banner = page.getByTestId("builtin-updates");
|
||||
await expect(banner).toContainText("The built-in privacy notice has been updated");
|
||||
await expect(banner).toContainText("How people agree to the notice");
|
||||
const marked = page.getByTestId("section-has-new-text");
|
||||
await expect(marked).toHaveCount(1);
|
||||
await marked.getByText("New built-in text for this section — show it").click();
|
||||
await marked.getByRole("button", { name: "Use the new text" }).click();
|
||||
await expect(page.getByTestId("section-has-new-text")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Save draft" }).click();
|
||||
await expect(page.getByText("Draft saved.")).toBeVisible();
|
||||
|
||||
await banner.getByRole("button", { name: "Done — hide this" }).click();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("1. Introduction text")).toBeVisible();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await api(page, "admin", "DELETE", "/api/privacy/notice/draft");
|
||||
});
|
||||
|
||||
test("someone who never accepted the notice is asked once, after login", async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("hope_events_token", token), seed().noticeFirstUser.token);
|
||||
await page.goto("/dashboard/user");
|
||||
const dialog = page.getByRole("dialog", { name: "Our privacy notice" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("It explains what we keep about you");
|
||||
await dialog.getByRole("button", { name: "I accept" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByText("My Events").first()).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Our privacy notice" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the cookie button stays on a published notice", async ({ page }) => {
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByRole("button", { name: "Change my cookie choice" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a significant change is accepted once, after login", async ({ page }) => {
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
// Re-acceptance needs a summary of what changed.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user