Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d01ce993 | ||
|
|
0414854dec | ||
|
|
dd537a1a16 | ||
|
|
2240c8a703 | ||
|
|
a7c8148588 | ||
|
|
96d1e67b85 | ||
|
|
e5d615e398 |
@@ -7,6 +7,79 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
+32
-1
@@ -509,6 +509,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
|
||||
@@ -555,6 +556,17 @@ keeps its own registrations; for one event on several dates see multi-day events
|
||||
- **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
|
||||
|
||||
@@ -760,6 +772,23 @@ 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**. 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.
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
|
||||
@@ -780,6 +809,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `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`) |
|
||||
@@ -802,7 +832,8 @@ can't start, the same jobs run on in-process timers.
|
||||
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, 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**. **Not now** hides the update banner until the next version; urgent updates (security or
|
||||
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 →
|
||||
|
||||
@@ -117,6 +117,9 @@ 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. |
|
||||
|
||||
@@ -12,6 +12,35 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.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
|
||||
|
||||
@@ -78,6 +78,10 @@ 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
|
||||
|
||||
@@ -943,11 +943,57 @@ role linked to the room); checked-in ones are counted in `leaders`.
|
||||
| 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 |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
|
||||
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
|
||||
| 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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -163,6 +163,7 @@ model User {
|
||||
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -324,6 +325,7 @@ model Event {
|
||||
// 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])
|
||||
@@ -345,6 +347,7 @@ model EventSeries {
|
||||
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).
|
||||
@@ -360,6 +363,69 @@ model SeriesFollow {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
@@ -381,6 +447,7 @@ model EventOption {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -417,6 +484,7 @@ model OptionVariant {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -447,6 +515,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[]
|
||||
@@ -537,6 +609,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])
|
||||
@@ -740,6 +816,8 @@ enum AdminAuditAction {
|
||||
series_created
|
||||
series_updated
|
||||
series_deleted
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2172,3 +2250,49 @@ 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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,10 +13,13 @@ 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);
|
||||
@@ -87,7 +90,7 @@ async function statusPayload({ fresh = false } = {}) {
|
||||
urgentUpdate,
|
||||
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
|
||||
releases,
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
|
||||
notices: activeNotices(s.notices),
|
||||
};
|
||||
}
|
||||
@@ -139,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) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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) } : {}) };
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -1,8 +1,8 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow } = require('../utils/series');
|
||||
const { scopeFor, canView } = require('../utils/campusScope');
|
||||
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({
|
||||
@@ -69,6 +69,9 @@ 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 });
|
||||
@@ -92,7 +95,22 @@ const getSeriesBySlug = async (req, res) => {
|
||||
});
|
||||
const { upcoming, past } = splitEvents(events);
|
||||
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
|
||||
res.json({ ...series, upcoming, past, following });
|
||||
// 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); }
|
||||
};
|
||||
|
||||
@@ -105,4 +123,4 @@ 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 };
|
||||
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
|
||||
|
||||
@@ -145,17 +145,36 @@ 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;
|
||||
|
||||
@@ -16,6 +16,11 @@ const BLOCKED = [
|
||||
['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\/?$/],
|
||||
|
||||
@@ -186,6 +186,8 @@ app.use('/api/campuses', require('./routes/campusRoutes'));
|
||||
app.use('/api/kids', require('./routes/kidsRoutes'));
|
||||
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/series', require('./routes/seriesRoutes'));
|
||||
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
|
||||
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -1007,6 +1009,31 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/series/by-slug/:slug', auth:'public', desc:'The series page: details, upcoming and past public events, and following (signed in)' },
|
||||
{ method:'POST', path:'/api/series/:id/follow', auth:'user', desc:'Follow a series: an email (News & announcements) when a new event in it goes live' },
|
||||
{ method:'DELETE', path:'/api/series/:id/follow', auth:'user', desc:'Unfollow a series' },
|
||||
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
|
||||
]},
|
||||
|
||||
{ title: 'Outbound webhooks', base: '/api/outbound-webhooks', endpoints: [
|
||||
{ method:'GET', path:'/api/outbound-webhooks', auth:'admin', desc:'Endpoints (with last-week delivery counts per status) and the event types: registration.created, registration.paid, registration.cancelled, payment.received, payment.refunded, ticket.checked_in, user.created, event.published' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks', auth:'admin', desc:'Add an endpoint (https, public address). The answer holds the signing secret — shown only now. Also added to the POPIA operator register. Audited as webhook_endpoint_changed',
|
||||
request:{ body:{ name:'ChurchSuite sync', url:'https://example.com/hooks/cross-events', events:['registration.paid','ticket.checked_in'] } } },
|
||||
{ method:'PUT', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Change name, url, events or isActive' },
|
||||
{ method:'DELETE', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Delete an endpoint and its deliveries (its operator entry is marked inactive)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/rotate-secret', auth:'admin', desc:'A new signing secret (the old one stops at once)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/test', auth:'admin', desc:'Send a webhook.test now; answers { status, statusCode, error }' },
|
||||
{ method:'GET', path:'/api/outbound-webhooks/:id/deliveries?status=&limit=', auth:'admin', desc:'Delivery log, newest first, with payloads (kept 30 days)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/deliveries/:id/resend', auth:'admin', desc:'Send a delivery again now' },
|
||||
]},
|
||||
|
||||
{ title: 'Season passes', base: '/api/season-passes', endpoints: [
|
||||
{ method:'GET', path:'/api/season-passes?seriesId=', auth:'supervisor+', desc:'A series\x27 passes: events, normal prices, each event\x27s share, sold and unpaid counts' },
|
||||
{ method:'POST', path:'/api/season-passes', auth:'supervisor+', desc:'Create a pass. Events must be in the series; not youth (consent) events, staff-only or per-session tickets. Campus-scoped supervisors: only their campuses\x27 events. Audited as season_pass_changed',
|
||||
request:{ body:{ seriesId:'series-uuid', name:'2027 breakfasts', price:400, saleEndsAt:null, items:[{ eventId:'ev-uuid', eventOptionId:'opt-uuid', variantId:null }] } } },
|
||||
{ method:'PUT', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Change a pass (events and price only while nobody has bought it; 409 otherwise)' },
|
||||
{ method:'DELETE', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Delete a pass nobody has bought' },
|
||||
{ method:'POST', path:'/api/season-passes/:id/buy', auth:'user', desc:'Buy passes: a registration at every event (lines priced at each event\x27s share), then one checkout for the total (redirectUrl), or tickets straight away when free. 409 when not on sale or an event has too few places',
|
||||
request:{ body:{ quantity:2 } }, responses:[{ status:201, desc:'Bought', body:{ purchaseId:'uuid', total:800, paid:false, redirectUrl:'https://…', provider:'yoco', message:null } }] },
|
||||
{ method:'POST', path:'/api/season-passes/purchases/:id/checkout', auth:'user', desc:'Pay what is still owed on my purchase (a new checkout)' },
|
||||
{ method:'GET', path:'/api/season-passes/mine', auth:'user', desc:'My pass purchases with their registrations' },
|
||||
]},
|
||||
|
||||
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
|
||||
|
||||
@@ -49,6 +49,10 @@ 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(),
|
||||
|
||||
@@ -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,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;
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = 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);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries } = require('../controllers/seriesController');
|
||||
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const { campusIndependent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
|
||||
|
||||
@@ -15,5 +15,7 @@ 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -465,6 +465,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
|
||||
// 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 } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
@@ -488,7 +490,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed] = 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' },
|
||||
@@ -515,6 +517,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
require('./kids').exportFamily(userId, db),
|
||||
require('./volunteers').exportVolunteering(userId, db),
|
||||
require('./series').exportFollows(userId, db),
|
||||
require('./seasonPass').exportPurchases(userId, db),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -559,6 +562,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
kidsFamily: family,
|
||||
volunteering,
|
||||
seriesFollowed,
|
||||
seasonPasses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -619,6 +623,10 @@ 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)}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -149,6 +149,83 @@ function announcementEmail({ name, event, series }) {
|
||||
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". */
|
||||
@@ -159,5 +236,5 @@ async function exportFollows(userId, db = prisma) {
|
||||
|
||||
module.exports = {
|
||||
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
|
||||
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError,
|
||||
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError, withChanges, compareSeries,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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));
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -148,12 +148,18 @@ 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' },
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,49 @@ describe('event series', () => {
|
||||
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' } };
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
+42
-3
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.47.1** (and Hub 1.4.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.48.0** (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,6 +14,10 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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 |
|
||||
@@ -49,8 +53,8 @@ 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. The rest of §6, one patch per part: **1.47.2 year-on-year reports** (compare a series' events),
|
||||
**1.47.3 season pass** (a bundle across a series' events) — then **1.48.0 — outbound webhooks** (§8).
|
||||
1. **1.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
|
||||
and 1.51.0 accessibility.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -313,6 +317,41 @@ a test server (both done 2026-10-28).
|
||||
`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`).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+12
-7
@@ -34,8 +34,9 @@ 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).
|
||||
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) shipped.
|
||||
**Next: 1.47.2 Year-on-year reports.**
|
||||
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. **Next: 1.49.0 Admin dashboard, command
|
||||
palette and bulk actions.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -475,9 +476,11 @@ year-on-year reports, **1.47.3** season pass.
|
||||
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
|
||||
- **Year-on-year reports (1.47.2 ✅):** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass (1.47.3):** 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -498,7 +501,9 @@ year-on-year reports, **1.47.3** season pass.
|
||||
|
||||
## 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`,
|
||||
@@ -962,7 +967,7 @@ 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.47.1 ✅ – 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 |
|
||||
| 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.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 |
|
||||
| 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 |
|
||||
@@ -1017,7 +1022,7 @@ feature.
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | 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 (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
|
||||
|
||||
@@ -144,12 +144,19 @@ 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`.
|
||||
|
||||
+50
-1
@@ -1,4 +1,4 @@
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
import { test, expect, loginAs, api, seed, API } from "./fixtures";
|
||||
|
||||
// Event series (1.47.1): a supervisor makes a series, events join it, the public series page
|
||||
// lists them, an event page links to the next one, a user follows it, and the series job tells
|
||||
@@ -62,4 +62,53 @@ test.describe.serial("event series", () => {
|
||||
const again = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
|
||||
expect(again.output.announced).toBe(0);
|
||||
});
|
||||
|
||||
test("season pass: a supervisor sets one up, a user buys two, each event gets its share", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
const row = page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" });
|
||||
await row.getByRole("button", { name: "+ New pass" }).click();
|
||||
await row.getByLabel("Pass name").fill("E2E Breakfast pass");
|
||||
await row.getByLabel("Price (R)").fill("30");
|
||||
// The two upcoming breakfasts are ticked; both tickets are free normally, so the split is equal.
|
||||
await row.getByRole("button", { name: "Create pass" }).click();
|
||||
await expect(row.getByTestId("season-pass")).toContainText("E2E Breakfast pass");
|
||||
await expect(row.getByTestId("season-pass")).toContainText("share R 15.00");
|
||||
|
||||
// E2E has no online payment provider, so the places are booked and wait for payment.
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/series/${series.slug}`);
|
||||
const offer = page.getByTestId("season-pass-offer").filter({ hasText: "E2E Breakfast pass" });
|
||||
await offer.getByLabel("How many of E2E Breakfast pass").selectOption("2");
|
||||
await offer.getByRole("button", { name: "Buy · R 60.00" }).click();
|
||||
await expect(page.getByTestId("season-pass-offers").getByRole("status")).toContainText("Your places are booked");
|
||||
await expect(page.getByTestId("my-passes")).toContainText("2 × E2E Breakfast pass");
|
||||
await expect(page.getByTestId("my-passes").getByRole("button", { name: "Pay now" })).toBeVisible();
|
||||
|
||||
type Mine = { id: string; total: number; registrations: { id: string; event: { title: string } }[] }[];
|
||||
const [purchase] = await api<Mine>(page, "user", "GET", "/api/season-passes/mine");
|
||||
expect(purchase.total).toBe(60);
|
||||
expect(purchase.registrations.map((r) => r.event.title).sort()).toEqual(["E2E Breakfast (in two months)", "E2E Breakfast (next month)"]);
|
||||
// Each event's registration owes its share: 2 × R15.
|
||||
type Reg = { id: string; totalDueComputed?: number; registrationOptions: { quantity: number; priceSnapshot: number }[] };
|
||||
const regs = await api<Reg[]>(page, "supervisor", "GET", `/api/registrations/event/${next.id}`);
|
||||
const fromPass = regs.find((r) => r.id === purchase.registrations.find((x) => x.event.title === "E2E Breakfast (next month)")!.id)!;
|
||||
expect(fromPass.registrationOptions[0]).toMatchObject({ quantity: 2, priceSnapshot: 15 });
|
||||
|
||||
// A pass registration keeps its tickets as bought: no codes.
|
||||
const res = await page.request.post(`${API}/api/registrations/${fromPass.id}/coupon`, {
|
||||
headers: { Authorization: `Bearer ${seed().users.user.token}` }, data: { code: "ANY" },
|
||||
});
|
||||
expect(res.status()).toBe(409);
|
||||
});
|
||||
|
||||
test("year on year: the series report compares its events, oldest first", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
await page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" }).getByRole("link", { name: "Compare" }).click();
|
||||
const rows = page.getByTestId("comparison-row");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(rows.first()).toContainText("E2E Breakfast (last month)");
|
||||
await expect(rows.last()).toContainText("E2E Breakfast (in two months)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
import { test, expect, loginAs, API } from "./fixtures";
|
||||
|
||||
// Outbound webhooks (1.48.0): an admin adds an endpoint (a receiver this test runs on localhost),
|
||||
// sends a test, and a new account is delivered — signed with the endpoint's secret.
|
||||
type Received = { headers: http.IncomingHttpHeaders; body: string };
|
||||
const RECEIVER_PORT = 5199;
|
||||
|
||||
test.describe.serial("outbound webhooks", () => {
|
||||
const received: Received[] = [];
|
||||
let server: http.Server;
|
||||
let secret = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; });
|
||||
req.on("end", () => { received.push({ headers: req.headers, body }); res.writeHead(200).end("ok"); });
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(RECEIVER_PORT, resolve));
|
||||
});
|
||||
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
|
||||
|
||||
const signed = (r: Received) => {
|
||||
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(String(r.headers["x-crossevents-signature"]));
|
||||
return !!m && crypto.createHmac("sha256", secret).update(`${m[1]}.${r.body}`).digest("hex") === m[2];
|
||||
};
|
||||
|
||||
test("an admin adds a webhook and sends a test", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
await page.getByRole("button", { name: "+ Add webhook" }).click();
|
||||
await page.getByLabel("Name").fill("E2E receiver");
|
||||
await page.getByLabel("Address (https)").fill(`http://localhost:${RECEIVER_PORT}/hook`);
|
||||
await page.getByText("Account created", { exact: true }).click();
|
||||
await page.getByText("Registration made", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add webhook" }).click();
|
||||
|
||||
await expect(page.getByTestId("webhook-secret")).toBeVisible();
|
||||
secret = (await page.getByTestId("webhook-secret").locator("code").first().innerText()).trim();
|
||||
expect(secret).toMatch(/^whsec_/);
|
||||
await page.getByRole("button", { name: "I've saved it" }).click();
|
||||
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Send test" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("Test delivered (answered 200)");
|
||||
const testHook = received.find(r => r.headers["x-crossevents-event"] === "webhook.test");
|
||||
expect(testHook && signed(testHook)).toBe(true);
|
||||
});
|
||||
|
||||
test("a new account is sent, signed, and shows in the delivery log", async ({ page, request }) => {
|
||||
const email = `webhook-${Date.now()}@test.local`;
|
||||
const res = await request.post(`${API}/api/users`, { data: { name: "Webhook Person", email, password: "E2e-password-123", phoneNumber: "0820000123" } });
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
await expect.poll(() => received.filter(r => r.headers["x-crossevents-event"] === "user.created").length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const hook = received.find(r => r.headers["x-crossevents-event"] === "user.created")!;
|
||||
expect(signed(hook)).toBe(true);
|
||||
const body = JSON.parse(hook.body);
|
||||
expect(body).toMatchObject({ type: "user.created", data: { person: { name: "Webhook Person", email } } });
|
||||
expect(body.id).toMatch(/^evt_/);
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Delivery log" }).click();
|
||||
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -120,6 +120,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
series_created: "Event series created",
|
||||
series_updated: "Event series changed",
|
||||
series_deleted: "Event series deleted",
|
||||
season_pass_changed: "Season pass created, changed or deleted",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -7,20 +7,21 @@ import Link from "next/link";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { LicenceTab } from "@/components/admin/LicenceTab";
|
||||
import { WebhooksTab } from "@/components/admin/WebhooksTab";
|
||||
import { CampusesTab } from "@/components/admin/CampusesTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -31,6 +32,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "payments", label: "Payments", icon: CreditCard },
|
||||
{ id: "webhooks", label: "Webhooks", icon: Webhook },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
{ id: "licence", label: "Licence", icon: KeyRound },
|
||||
@@ -716,6 +718,7 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "webhooks" && <WebhooksTab active={activeTab === "webhooks"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Layers, ExternalLink, Users } from "lucide-react";
|
||||
import { Layers, ExternalLink, Users, BarChart3 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { SeasonPassesPanel } from "@/components/series/SeasonPassesPanel";
|
||||
import type { EventSeries } from "@/types";
|
||||
|
||||
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
|
||||
@@ -151,6 +152,11 @@ export default function SeriesPage() {
|
||||
{s.description && <p className="text-sm text-gray-600 mt-1 whitespace-pre-line">{s.description}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{s.events.length > 0 && (
|
||||
<Link href={`/dashboard/supervisor/reports?report=seriesComparison&series=${s.id}`} className="inline-flex items-center gap-1 text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50">
|
||||
<BarChart3 className="w-3.5 h-3.5" /> Compare
|
||||
</Link>
|
||||
)}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50"
|
||||
onClick={() => { setDraft({ id: s.id, name: s.name, slug: s.slug, description: s.description || "", picture: s.picture || "" }); setNotice(null); }}>Edit</button>
|
||||
{isAdmin && <button type="button" className="text-xs px-3 py-1.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(s)}>Delete</button>}
|
||||
@@ -179,6 +185,7 @@ export default function SeriesPage() {
|
||||
<p className="text-[11px] text-gray-400 mt-1">{s.eventCount - s.events.length} more at other campuses.</p>
|
||||
)}
|
||||
</div>
|
||||
{s.events.length > 0 && <SeasonPassesPanel seriesId={s.id} seriesSlug={s.slug} />}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -7,16 +7,18 @@ import { EventCard } from "@/components/events/EventCard";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
|
||||
import { SeasonPassOffers } from "@/components/series/SeasonPassOffers";
|
||||
import { Suspense } from "react";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
|
||||
import type { Event as FullEvent } from "@/types";
|
||||
import type { Event as FullEvent, SeasonPassView } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
|
||||
type PastEvent = { id: string; title: string; startDate: string; endDate: string; campus?: FullEvent["campus"] };
|
||||
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[] };
|
||||
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[]; passes?: SeasonPassView[] };
|
||||
|
||||
async function loadSeries(slug: string): Promise<Series | null> {
|
||||
try {
|
||||
@@ -69,6 +71,10 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<SeasonPassOffers slug={series.slug} passes={series.passes || []} />
|
||||
</Suspense>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Coming up</h2>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50 mb-10">
|
||||
|
||||
@@ -23,6 +23,7 @@ const STATE: Record<string, string> = {
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
|
||||
function describeCron(cron: string): string {
|
||||
if (cron === "* * * * *") return "Every minute";
|
||||
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
|
||||
if (m) return `Every ${m[1]} minutes`;
|
||||
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
|
||||
|
||||
@@ -24,7 +24,8 @@ export type HubStatus = {
|
||||
urgentUpdate: boolean;
|
||||
updateSnoozed: boolean;
|
||||
releases: HubRelease[];
|
||||
updateRequest: { requestedAt: string; version: string } | null;
|
||||
// `outdated`: a newer version than the one asked for is out (the request can be changed to it).
|
||||
updateRequest: { requestedAt: string; version: string; outdated?: boolean } | null;
|
||||
notices: HubNotice[];
|
||||
};
|
||||
|
||||
@@ -138,6 +139,8 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
}
|
||||
};
|
||||
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
|
||||
// Same request again: the Hub moves the open request on to the latest version.
|
||||
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
|
||||
const withdrawRequest = () => {
|
||||
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
|
||||
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
|
||||
@@ -208,12 +211,26 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
{status.updateRequest ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-gray-600">Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.</p>
|
||||
{status.connected && (
|
||||
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<Undo2 className="w-4 h-4" /> Withdraw request
|
||||
</button>
|
||||
<div className="space-y-2" data-testid="update-request">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-gray-600">
|
||||
You asked for version <strong>{status.updateRequest.version}</strong> on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
|
||||
</p>
|
||||
{status.connected && (
|
||||
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<Undo2 className="w-4 h-4" /> Withdraw request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.updateRequest.outdated && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-brand-200 bg-brand-50 p-3">
|
||||
<p className="text-brand-800">Version <strong>{status.latestVersion}</strong> has come out since. Ask for it instead, and your provider installs that one.</p>
|
||||
{status.connected && (
|
||||
<button onClick={changeRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : `Ask for ${status.latestVersion} instead`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type EventType = { type: string; label: string; description: string };
|
||||
type Endpoint = {
|
||||
id: string; name: string; url: string; events: string[]; isActive: boolean;
|
||||
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
|
||||
lastWeek?: Record<string, number>;
|
||||
};
|
||||
type Delivery = {
|
||||
id: string; eventId: string; type: string; status: string; attempts: number; nextAttemptAt: string;
|
||||
lastStatusCode: number | null; lastError: string | null; deliveredAt: string | null; createdAt: string; payload: unknown;
|
||||
};
|
||||
type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endpoint[] };
|
||||
type Draft = { id?: string; name: string; url: string; events: string[] };
|
||||
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
const STATUS: Record<string, string> = {
|
||||
succeeded: "bg-green-50 text-green-800", failed: "bg-red-50 text-red-700", retrying: "bg-amber-50 text-amber-800",
|
||||
pending: "bg-gray-100 text-gray-600", sending: "bg-blue-50 text-blue-700",
|
||||
};
|
||||
|
||||
/** Shown once after adding an endpoint or making a new secret. */
|
||||
function SecretBox({ secret, onClose }: { secret: string; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm space-y-2" data-testid="webhook-secret">
|
||||
<p className="font-medium text-amber-900">Signing secret — copy it now, it won't be shown again</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded bg-white border px-2 py-1 text-xs">{secret}</code>
|
||||
<button type="button" className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"
|
||||
onClick={() => { navigator.clipboard?.writeText(secret).then(() => setCopied(true)).catch(() => {}); }}>
|
||||
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-amber-900">
|
||||
Each request has an <code>X-CrossEvents-Signature: t=…,v1=…</code> header: v1 is the HMAC-SHA256 (hex) of <code><t>.<body></code> with this secret. Check it, and that t is recent, before trusting a request.
|
||||
</p>
|
||||
<button type="button" className="text-xs underline text-amber-900" onClick={onClose}>I've saved it</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Deliveries({ endpointId, token }: { endpointId: string; token: string }) {
|
||||
const [rows, setRows] = useState<Delivery[] | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const load = useCallback(async () => {
|
||||
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
|
||||
}, [endpointId, token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const resend = async (id: string) => {
|
||||
setBusy(id);
|
||||
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
|
||||
setBusy(null);
|
||||
load();
|
||||
};
|
||||
if (!rows) return <p className="text-xs text-gray-400">Loading…</p>;
|
||||
if (!rows.length) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
|
||||
return (
|
||||
<div className="space-y-1" data-testid="webhook-deliveries">
|
||||
<div className="flex justify-end"><button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" /> Refresh</button></div>
|
||||
<ul className="divide-y border rounded-lg bg-white">
|
||||
{rows.map(d => (
|
||||
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen(open === d.id ? null : d.id)} className="text-gray-500" aria-label="Show what was sent">
|
||||
{open === d.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<span className="text-gray-500 w-28">{when(d.createdAt)}</span>
|
||||
<code className="font-medium">{d.type}</code>
|
||||
<span className={`px-1.5 py-0.5 rounded ${STATUS[d.status] || "bg-gray-100"}`}>{d.status}</span>
|
||||
<span className="text-gray-500">
|
||||
{d.attempts} tr{d.attempts === 1 ? "y" : "ies"}{d.lastStatusCode ? ` · ${d.lastStatusCode}` : ""}{d.status === "retrying" ? ` · next ${when(d.nextAttemptAt)}` : ""}
|
||||
</span>
|
||||
{d.lastError && <span className="text-red-700 truncate max-w-[16rem]" title={d.lastError}>{d.lastError}</span>}
|
||||
{d.status !== "sending" && (
|
||||
<button type="button" disabled={busy === d.id} onClick={() => resend(d.id)} className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">
|
||||
<RotateCcw className="w-3 h-3" /> Resend
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open === d.id && <pre className="mx-3 mb-2 max-h-64 overflow-auto rounded bg-gray-50 p-2 text-[11px]">{JSON.stringify(d.payload, null, 2)}</pre>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings → Webhooks: send what happens here (registrations, payments, check-ins…) to other systems.
|
||||
export function WebhooksTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<ListResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [secret, setSecret] = useState<{ endpointId: string; value: string } | null>(null);
|
||||
const [openLog, setOpenLog] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true); setMsg(null);
|
||||
try { await fn(); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const save = () => run(async () => {
|
||||
if (!draft) return;
|
||||
const body = { name: draft.name, url: draft.url, events: draft.events };
|
||||
if (draft.id) {
|
||||
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
setMsg({ type: "ok", text: "Webhook saved." });
|
||||
} else {
|
||||
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
|
||||
setSecret({ endpointId: made.id, value: made.secret });
|
||||
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
|
||||
}
|
||||
setDraft(null);
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggle = (e: Endpoint) => run(async () => {
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
|
||||
await load();
|
||||
});
|
||||
const test = (e: Endpoint) => run(async () => {
|
||||
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
|
||||
setMsg(r.status === "succeeded"
|
||||
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
|
||||
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
|
||||
if (openLog === e.id) setOpenLog(null);
|
||||
await load();
|
||||
});
|
||||
const rotate = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
|
||||
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
|
||||
setSecret({ endpointId: e.id, value: r.secret });
|
||||
});
|
||||
const remove = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
|
||||
|
||||
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><Webhook className="w-5 h-5" /> Webhooks</h2>
|
||||
<p className="text-sm text-gray-500 max-w-2xl">
|
||||
Send what happens here — registrations, payments, check-ins, new accounts — to another system as it happens (e.g. ChurchSuite or Planning Center through Zapier or Make).
|
||||
</p>
|
||||
</div>
|
||||
{!draft && <button type="button" onClick={() => { setMsg(null); setDraft({ name: "", url: "", events: [] }); }} className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 font-medium">+ Add webhook</button>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 bg-gray-50 border rounded-lg p-3">
|
||||
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children's details from kids check-in are never sent.
|
||||
</p>
|
||||
|
||||
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
|
||||
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
|
||||
|
||||
{draft && (
|
||||
<div className="border rounded-xl p-4 space-y-3 bg-white" data-testid="webhook-form">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-name">Name</label>
|
||||
<input id="webhook-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. ChurchSuite sync" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-url">Address (https)</label>
|
||||
<input id="webhook-url" className="w-full border rounded px-3 py-2 text-sm" value={draft.url} onChange={e => setDraft({ ...draft, url: e.target.value })} placeholder="https://…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Send when</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{data.eventTypes.map(t => (
|
||||
<label key={t.type} className="flex items-start gap-2 text-sm border rounded-lg p-2 cursor-pointer hover:bg-gray-50">
|
||||
<input type="checkbox" className="mt-1" checked={draft.events.includes(t.type)} onChange={() => toggleEvent(t.type)} />
|
||||
<span><span className="font-medium">{t.label}</span> <code className="text-[10px] text-gray-500">{t.type}</code><span className="block text-xs text-gray-500">{t.description}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={busy || !draft.name.trim() || !draft.url.trim() || !draft.events.length} onClick={save} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Saving…" : draft.id ? "Save webhook" : "Add webhook"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.endpoints.length === 0 && !draft ? (
|
||||
<p className="text-sm text-gray-500 border rounded-xl p-4">No webhooks yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.endpoints.map(e => (
|
||||
<li key={e.id} className="border rounded-xl p-4 bg-white space-y-2" data-testid="webhook-endpoint">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{e.name}
|
||||
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Off</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 break-all">{e.url}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">{e.events.map(t => <span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">{label(t)}</span>)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Last delivered {when(e.lastSuccessAt)}{e.lastFailureAt && <> · last failure {when(e.lastFailureAt)}</>}
|
||||
{e.lastWeek && Object.keys(e.lastWeek).length > 0 && <> · this week: {Object.entries(e.lastWeek).map(([s, n]) => `${n} ${s}`).join(", ")}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busy} onClick={() => test(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send test</button>
|
||||
<button type="button" onClick={() => { setMsg(null); setDraft({ id: e.id, name: e.name, url: e.url, events: e.events }); }} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
|
||||
<button type="button" disabled={busy} onClick={() => toggle(e)} className="text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">{e.isActive ? "Switch off" : "Switch on"}</button>
|
||||
<button type="button" disabled={busy} onClick={() => rotate(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><KeyRound className="w-3.5 h-3.5" /> New secret</button>
|
||||
<button type="button" disabled={busy} onClick={() => remove(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded bg-red-50 text-red-700 border border-red-200 disabled:opacity-50"><Trash2 className="w-3.5 h-3.5" /> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenLog(openLog === e.id ? null : e.id)} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline">
|
||||
{openLog === e.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Delivery log
|
||||
</button>
|
||||
{openLog === e.id && token && <Deliveries endpointId={e.id} token={token} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
HeartPulse,
|
||||
MessageSquareHeart,
|
||||
Filter,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -42,6 +43,7 @@ export const REPORTS = [
|
||||
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
|
||||
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "seriesComparison", label: "Series: year on year", description: "Registrations, income and attendance compared across the events in a series", category: "Other", icon: Layers, fields: 6 },
|
||||
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
@@ -15,7 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payment, Registration, Ticket, UserSummary } from "@/types";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
@@ -147,6 +147,9 @@ type FunnelReport = {
|
||||
perEvent?: { eventId: string; title: string; totals: FunnelCounts; conversion: FunnelConversion }[];
|
||||
};
|
||||
|
||||
/** "+12%", "−5%", "no change" — a year-on-year change. */
|
||||
const changeLabel = (c: number) => (c > 0 ? `+${c}%` : c < 0 ? `−${Math.abs(c)}%` : "no change");
|
||||
|
||||
// Simple MultiSelect control used in filters
|
||||
export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const { token, user } = useAuth();
|
||||
@@ -241,6 +244,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const [feedbackRows, setFeedbackRows] = useState<FeedbackEventSummary[]>([]);
|
||||
// Registration funnel for the selected events (null until viewed).
|
||||
const [funnel, setFunnel] = useState<FunnelReport | null>(null);
|
||||
// Series: year on year — picked by series, not by the events selection.
|
||||
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
|
||||
const [compareSeriesId, setCompareSeriesId] = useState<string>("");
|
||||
const [comparison, setComparison] = useState<SeriesComparison | null>(null);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => {
|
||||
const list = Array.isArray(l) ? l : [];
|
||||
setSeriesList(list);
|
||||
setCompareSeriesId(id => id || list[0]?.id || "");
|
||||
}).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
@@ -259,6 +274,8 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
deepLinkHandledRef.current = true;
|
||||
setReport(reportParam as ReportKey);
|
||||
setSelectedEventIds(filteredEvents.map((e) => e.id));
|
||||
const seriesParam = searchParams.get("series");
|
||||
if (seriesParam) setCompareSeriesId(seriesParam);
|
||||
if (searchParams.get("range") === "trailing_month") {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
|
||||
@@ -527,6 +544,10 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
setFeedbackRows(await loadFeedback(selectedEventIds));
|
||||
} else if (report === "medicalList") {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
} else if (report === "seriesComparison") {
|
||||
setComparison(compareSeriesId && token
|
||||
? await apiFetch<SeriesComparison>(`/api/series/${encodeURIComponent(compareSeriesId)}/compare`, { authToken: token }).catch(() => null)
|
||||
: null);
|
||||
}
|
||||
setReady(true);
|
||||
} finally {
|
||||
@@ -959,6 +980,24 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// mirror exactly what the on-screen report shows above its table, computed the same way.
|
||||
const buildReportPayload = (): ReportPdfPayload | null => {
|
||||
const subtitle = `Generated ${new Date().toLocaleString()}`;
|
||||
if (report === "seriesComparison") {
|
||||
if (!comparison) return null;
|
||||
const withChange = (v: string, c: number | null | undefined) => (c == null ? v : `${v} (${changeLabel(c)})`);
|
||||
return {
|
||||
title: `${comparison.series.name}: year on year`, subtitle, kind: "table", orientation: "landscape",
|
||||
note: "Each change is against the event before. Checked in on a multi-session event is the average per session. Income leaves out donations applied to orders (counted when they were given).",
|
||||
...(comparison.events.length > 1 ? { chart: { title: "Income, by event", data: comparison.events.map(e => ({ label: e.title, value: e.income, displayValue: `R ${e.income.toFixed(2)}` })) } } : {}),
|
||||
table: {
|
||||
columns: ["Event", "Date", "Registrations", "Tickets", "Income", "Checked in", "Attendance"],
|
||||
rows: comparison.events.map(e => [
|
||||
e.title, new Date(e.startDate).toLocaleDateString(),
|
||||
withChange(String(e.registrations), e.change?.registrations), withChange(String(e.tickets), e.change?.tickets),
|
||||
withChange(`R ${e.income.toFixed(2)}`, e.change?.income), withChange(String(e.checkedIn), e.change?.checkedIn),
|
||||
e.attendanceRate != null ? `${e.attendanceRate}%` : "—",
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "payments") {
|
||||
return {
|
||||
title: 'Payments Report', subtitle, kind: 'table', orientation: 'portrait',
|
||||
@@ -1372,6 +1411,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
discounts: "Discounts Report",
|
||||
consentStatus: "Parental Consent Status",
|
||||
medicalList: "Medical & Emergency List",
|
||||
seriesComparison: "Series: year on year",
|
||||
};
|
||||
|
||||
const doPrintPdf = async () => {
|
||||
@@ -1412,7 +1452,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// Report-specific filters shown inside the popup — whatever each report had beyond the
|
||||
// universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null`
|
||||
// when a report has none, so the modal chrome shows its own fallback note.
|
||||
const reportSpecificFilters = report === "attendees" ? (
|
||||
const reportSpecificFilters = report === "seriesComparison" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mr-2">Series</span>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={compareSeriesId} onChange={e => setCompareSeriesId(e.target.value)} aria-label="Series">
|
||||
{seriesList.length === 0 && <option value="">No series yet</option>}
|
||||
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">Press refresh after changing the series. The events selection on the left doesn't apply.</span>
|
||||
</div>
|
||||
) : report === "attendees" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mr-2">Event</span>
|
||||
@@ -1869,6 +1920,60 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{report === "seriesComparison" && (
|
||||
<div data-testid="series-comparison">
|
||||
{!comparison ? (
|
||||
<div className="text-sm text-gray-500">{seriesList.length === 0 ? "No series yet — make one on the Event series page and put events in it." : "Choose a series above."}</div>
|
||||
) : comparison.events.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">{comparison.series.name} has no events yet (or none at your campuses).</div>
|
||||
) : (() => {
|
||||
const last = comparison.events[comparison.events.length - 1];
|
||||
const change = (c: number | null | undefined) => c == null ? null : <span className={`ml-1 text-xs ${c > 0 ? "text-green-700" : c < 0 ? "text-rose-700" : "text-gray-500"}`}>{changeLabel(c)}</span>;
|
||||
return (
|
||||
<>
|
||||
<StatTileRow>
|
||||
<StatTile icon={Users} label={`Registrations, ${last.title}`} value={`${last.registrations}${last.change?.registrations != null ? ` (${changeLabel(last.change.registrations)})` : ""}`} tone="violet" />
|
||||
<StatTile icon={TrendingUp} label="Income" value={`R ${last.income.toFixed(2)}${last.change?.income != null ? ` (${changeLabel(last.change.income)})` : ""}`} tone="green" />
|
||||
<StatTile icon={TicketIcon} label="Attendance" value={last.attendanceRate != null ? `${last.attendanceRate}%` : "—"} tone="blue" />
|
||||
</StatTileRow>
|
||||
{comparison.events.length > 1 && (
|
||||
<div className="mt-4 grid md:grid-cols-2 gap-4">
|
||||
<div className="border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Registrations, by event</div>
|
||||
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.registrations }))} valueFormatter={v => String(v)} />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Income, by event</div>
|
||||
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.income }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500 border-b">
|
||||
<th className="py-2 pr-3">Event</th><th className="py-2 pr-3">Date</th><th className="py-2 pr-3">Registrations</th><th className="py-2 pr-3">Tickets</th><th className="py-2 pr-3">Income</th><th className="py-2 pr-3">Checked in</th><th className="py-2">Attendance</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{comparison.events.map(e => (
|
||||
<tr key={e.eventId} className="border-b last:border-0" data-testid="comparison-row">
|
||||
<td className="py-2 pr-3 font-medium">{e.title}{e.campus && <span className="ml-1 text-[10px] text-gray-500">· {e.campus.name}</span>}</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{new Date(e.startDate).toLocaleDateString()}</td>
|
||||
<td className="py-2 pr-3">{e.registrations}{change(e.change?.registrations)}</td>
|
||||
<td className="py-2 pr-3">{e.tickets}{change(e.change?.tickets)}</td>
|
||||
<td className="py-2 pr-3">R {e.income.toFixed(2)}{change(e.change?.income)}</td>
|
||||
<td className="py-2 pr-3">{e.checkedIn}{e.sessions > 0 && <span className="text-[10px] text-gray-500"> avg/session</span>}{change(e.change?.checkedIn)}</td>
|
||||
<td className="py-2">{e.attendanceRate != null ? `${e.attendanceRate}%` : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Each change is against the event before. Income leaves out donations applied to orders (they count when they were given).</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{report === "feedbackSummary" && (
|
||||
<div>
|
||||
{feedbackSummary.perEvent.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Ticket, CheckCircle2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import type { SeasonPassPurchase, SeasonPassView } from "@/types";
|
||||
|
||||
const rand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
type BuyResult = { purchaseId: string; total: number; paid: boolean; redirectUrl: string | null; message: string | null };
|
||||
|
||||
/**
|
||||
* The season passes on a series page: what each includes and costs, Buy (registers you for every
|
||||
* event on it, then one payment), and your own passes for this series with "Pay now" when unpaid.
|
||||
*/
|
||||
export function SeasonPassOffers({ slug, passes: initial }: { slug: string; passes: SeasonPassView[] }) {
|
||||
const { user, token, loading } = useAuth();
|
||||
const params = useSearchParams();
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [mine, setMine] = useState<SeasonPassPurchase[]>([]);
|
||||
// The page is cached for everyone; ask for the passes as they are now.
|
||||
const [passes, setPasses] = useState<SeasonPassView[]>(initial);
|
||||
useEffect(() => {
|
||||
apiFetch<{ passes?: SeasonPassView[] }>(`/api/series/by-slug/${encodeURIComponent(slug)}`).then(s => setPasses(s.passes || [])).catch(() => {});
|
||||
}, [slug]);
|
||||
|
||||
const loadMine = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const list = await apiFetch<SeasonPassPurchase[]>("/api/season-passes/mine", { authToken: token }).catch(() => []);
|
||||
setMine(list.filter(p => p.pass.series.slug === slug && p.status !== "cancelled"));
|
||||
}, [token, slug]);
|
||||
useEffect(() => { loadMine(); }, [loadMine]);
|
||||
|
||||
const returned = params.get("pass");
|
||||
|
||||
const go = (res: BuyResult | { redirectUrl: string | null; message: string | null }) => {
|
||||
if (res.redirectUrl) { window.location.href = res.redirectUrl; return; }
|
||||
setNotice(res.message || "Done.");
|
||||
loadMine();
|
||||
};
|
||||
|
||||
const buy = async (p: SeasonPassView) => {
|
||||
setBusy(p.id); setError(null); setNotice(null);
|
||||
try {
|
||||
go(await apiFetch<BuyResult>(`/api/season-passes/${p.id}/buy`, { method: "POST", authToken: token || undefined, body: { quantity: qty[p.id] || 1 } }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't buy the pass");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pay = async (purchaseId: string) => {
|
||||
setBusy(purchaseId); setError(null); setNotice(null);
|
||||
try {
|
||||
go(await apiFetch<{ redirectUrl: string | null; message: string | null }>(`/api/season-passes/purchases/${purchaseId}/checkout`, { method: "POST", authToken: token || undefined }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't start the payment");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (passes.length === 0 && mine.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mb-10" data-testid="season-pass-offers">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1 flex items-center gap-2"><Ticket className="w-5 h-5 text-brand-600" /> Season passes</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">One payment, a ticket for every event on the pass.</p>
|
||||
{returned === "paid" && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm">Thank you! Your tickets are on their way by email once the payment comes through.</div>}
|
||||
{(returned === "cancelled" || returned === "failed") && <div className="p-3 mb-3 border rounded bg-amber-50 text-amber-800 text-sm">The payment didn't go through. Your places are kept — use "Pay now" below to try again.</div>}
|
||||
{notice && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm" role="status">{notice}</div>}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{mine.length > 0 && (
|
||||
<div className="border rounded-xl bg-white p-4 mb-4" data-testid="my-passes">
|
||||
<div className="text-sm font-medium text-gray-900 mb-2">Your passes</div>
|
||||
<ul className="space-y-2">
|
||||
{mine.map(m => (
|
||||
<li key={m.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span>{m.quantity} × {m.pass.name} · {rand(m.total)} · {m.status === "paid" ? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> paid — tickets in My events</span> : <span className="text-amber-700">not paid yet</span>}</span>
|
||||
{m.status === "pending" && (
|
||||
<button type="button" disabled={busy === m.id} onClick={() => pay(m.id)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Pay now</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{passes.map(p => (
|
||||
<div key={p.id} className="border rounded-xl bg-white shadow-sm p-5" data-testid="season-pass-offer">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{p.name}</h3>
|
||||
{p.description && <p className="text-sm text-gray-600 mt-0.5">{p.description}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-lg font-bold text-gray-900">{p.price === 0 ? "Free" : rand(p.price)}</div>
|
||||
{p.saving > 0 && <div className="text-xs text-green-700">save {rand(p.saving)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-sm text-gray-700">
|
||||
{p.items.map(i => (
|
||||
<li key={i.id} className="flex justify-between gap-2">
|
||||
<span className="min-w-0"><span className="font-medium">{i.eventTitle}</span> <span className="text-gray-500 text-xs">· {formatDateTimeRange(i.startDate, i.endDate)} · {i.ticket}</span></span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
{p.unavailable ? (
|
||||
<p className="text-sm text-gray-500">{p.unavailable}</p>
|
||||
) : loading ? null : !user ? (
|
||||
<a href={`/login?redirect=/series/${slug}`} className="inline-block px-4 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50">Log in to buy</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600 flex items-center gap-1.5">
|
||||
How many
|
||||
<select aria-label={`How many of ${p.name}`} className="border rounded px-2 py-1 text-sm" value={qty[p.id] || 1} onChange={e => setQty({ ...qty, [p.id]: Number(e.target.value) })}>
|
||||
{Array.from({ length: 10 }, (_, k) => k + 1).map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" disabled={busy === p.id} onClick={() => buy(p)} className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-50">
|
||||
{busy === p.id ? "Booking…" : p.price === 0 ? "Get the pass" : `Buy · ${rand(p.price * (qty[p.id] || 1))}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, SeasonPassView } from "@/types";
|
||||
|
||||
type ItemDraft = { eventId: string; on: boolean; eventOptionId: string; variantId: string };
|
||||
type Draft = { id?: string; name: string; description: string; price: string; saleEndsAt: string; isActive: boolean; items: ItemDraft[] };
|
||||
|
||||
const rand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
|
||||
/** The staff-only kids and volunteer tickets aren't sold on a pass. */
|
||||
const sellable = (ev: Event) => (ev.eventOptions || []).filter(o => o.id !== ev.kidsOptionId && o.id !== ev.volunteerOptionId && !(ev.attendanceMode === "pick" && o.sessionMode === "pick"));
|
||||
|
||||
/**
|
||||
* A series' season passes on the Event series page: what's on each, sold so far, and a form to
|
||||
* make or change one (events and price are fixed once someone has bought it).
|
||||
*/
|
||||
export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string; seriesSlug: string }) {
|
||||
const { token } = useAuth();
|
||||
const [passes, setPasses] = useState<SeasonPassView[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
// The series' events have arrived (the pass form needs them before it can be saved).
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const [p, all] = await Promise.all([
|
||||
apiFetch<SeasonPassView[]>(`/api/season-passes?seriesId=${encodeURIComponent(seriesId)}`, { authToken: token }).catch(() => []),
|
||||
apiFetch<Event[]>("/api/events/all?includeInactive=true", { authToken: token }).catch(() => []),
|
||||
]);
|
||||
setPasses(p);
|
||||
// Upcoming events in this series that take registrations, not youth events.
|
||||
setEvents((all || []).filter(e => e.seriesId === seriesId && e.requiresRegistration !== false && !e.requiresParentalConsent).sort((a, b) => a.startDate.localeCompare(b.startDate)));
|
||||
setLoaded(true);
|
||||
}, [token, seriesId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// A pass's own events; events without a row yet (a new pass, or still loading) take the default:
|
||||
// ticked on a new pass, with the first ticket that can be sold.
|
||||
const passItems = (p: SeasonPassView): ItemDraft[] => p.items.map(i => ({ eventId: i.eventId, on: true, eventOptionId: i.eventOptionId, variantId: i.variantId || "" }));
|
||||
const itemFor = (d: Draft, ev: Event): ItemDraft => d.items.find(i => i.eventId === ev.id)
|
||||
|| { eventId: ev.id, on: !d.id, eventOptionId: sellable(ev)[0]?.id || "", variantId: "" };
|
||||
|
||||
const openNew = () => { setError(null); setDraft({ name: "", description: "", price: "", saleEndsAt: "", isActive: true, items: [] }); };
|
||||
const openEdit = (p: SeasonPassView) => {
|
||||
setError(null);
|
||||
setDraft({ id: p.id, name: p.name, description: p.description || "", price: String(p.price), saleEndsAt: p.saleEndsAt ? p.saleEndsAt.slice(0, 16) : "", isActive: p.isActive, items: passItems(p) });
|
||||
};
|
||||
const editing = draft?.id ? passes.find(p => p.id === draft.id) : null;
|
||||
const locked = !!editing && ((editing.sold || 0) + (editing.unpaid || 0)) > 0;
|
||||
|
||||
// What the chosen tickets cost separately, to show the saving while typing the price.
|
||||
const normalValue = useMemo(() => (draft ? events.map(ev => itemFor(draft, ev)) : []).filter(i => i.on).reduce((s, i) => {
|
||||
const ev = events.find(e => e.id === i.eventId);
|
||||
const opt = ev?.eventOptions?.find(o => o.id === i.eventOptionId);
|
||||
const v = opt?.variants?.find(x => x.id === i.variantId);
|
||||
return s + Number(v?.price ?? opt?.price ?? 0);
|
||||
}, 0), [draft, events]);
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
setSaving(true); setError(null);
|
||||
try {
|
||||
const body = {
|
||||
seriesId, name: draft.name, description: draft.description, isActive: draft.isActive,
|
||||
saleEndsAt: draft.saleEndsAt ? new Date(draft.saleEndsAt).toISOString() : null,
|
||||
...(locked ? {} : {
|
||||
price: Number(draft.price),
|
||||
items: events.map(ev => itemFor(draft, ev)).filter(i => i.on).map(i => ({ eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId || null })),
|
||||
}),
|
||||
};
|
||||
await apiFetch(draft.id ? `/api/season-passes/${draft.id}` : "/api/season-passes", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
|
||||
setDraft(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't save the pass");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (p: SeasonPassView) => {
|
||||
if (!window.confirm(`Delete "${p.name}"?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/season-passes/${p.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't delete the pass");
|
||||
}
|
||||
};
|
||||
|
||||
const updItem = (ev: Event, patch: Partial<ItemDraft>) => setDraft(d => d && ({ ...d, items: [...d.items.filter(i => i.eventId !== ev.id), { ...itemFor(d, ev), ...patch }] }));
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t pt-3" data-testid="season-passes">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="text-xs font-medium text-gray-700 flex items-center gap-1"><Ticket className="w-3.5 h-3.5" /> Season passes</div>
|
||||
{!draft && <button type="button" className="text-xs px-2.5 py-1 rounded border bg-white hover:bg-gray-50" onClick={openNew}>+ New pass</button>}
|
||||
</div>
|
||||
{error && <div className="p-2 mb-2 border rounded bg-red-50 text-red-700 text-xs">{error}</div>}
|
||||
|
||||
{passes.length === 0 && !draft && <p className="text-xs text-gray-500">No passes. A pass sells a ticket for several events in this series at one price.</p>}
|
||||
<ul className="space-y-2">
|
||||
{passes.map(p => (
|
||||
<li key={p.id} className="border rounded-lg p-3 text-sm bg-gray-50/50" data-testid="season-pass">
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<span className="font-medium">{p.name}</span> · {rand(p.price)}
|
||||
{p.saving > 0 && <span className="text-xs text-green-700"> (saves {rand(p.saving)})</span>}
|
||||
{!p.isActive && <span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Off</span>}
|
||||
<div className="text-xs text-gray-500 mt-0.5">{p.sold || 0} sold{p.unpaid ? ` · ${p.unpaid} not paid yet` : ""}{p.unavailable && p.isActive ? ` · ${p.unavailable}` : ""}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a href={`/series/${seriesSlug}`} target="_blank" rel="noreferrer" className="text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50">View</a>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50" onClick={() => openEdit(p)}>Edit</button>
|
||||
{!(p.sold || p.unpaid) && <button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(p)}>Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-2 text-xs text-gray-600 space-y-0.5">
|
||||
{p.items.map(i => (
|
||||
<li key={i.id}>{i.eventTitle} ({new Date(i.startDate).toLocaleDateString()}) — {i.ticket}: normally {rand(i.normalPrice)}, share {rand(i.share)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{draft && (
|
||||
<div className="border rounded-lg p-3 bg-white space-y-3 mt-2" data-testid="season-pass-form">
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-name">Pass name</label>
|
||||
<input id="pass-name" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. All 2027 breakfasts" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-price">Price (R)</label>
|
||||
<input id="pass-price" type="number" min="0" step="1" disabled={locked} className="w-full border rounded px-2 py-1.5 text-sm disabled:bg-gray-50" value={draft.price} onChange={e => setDraft({ ...draft, price: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-description">Description (optional)</label>
|
||||
<input id="pass-description" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.description} onChange={e => setDraft({ ...draft, description: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Events on the pass</div>
|
||||
{locked && <p className="text-[11px] text-amber-700 mb-1">People have bought this pass, so its events and price can't change — switch it off and make a new one.</p>}
|
||||
{!loaded ? <p className="text-xs text-gray-500">Loading events…</p> : events.length === 0 ? <p className="text-xs text-gray-500">This series has no events that take registrations yet.</p> : (
|
||||
<ul className="space-y-1.5">
|
||||
{events.map(ev => {
|
||||
const item = itemFor(draft, ev);
|
||||
const opts = sellable(ev);
|
||||
const variants = opts.find(o => o.id === item.eventOptionId)?.variants || [];
|
||||
return (
|
||||
<li key={ev.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<input type="checkbox" disabled={locked} checked={item.on} onChange={e => updItem(ev, { on: e.target.checked })} />
|
||||
<span className="truncate">{ev.title}</span>
|
||||
<span className="text-xs text-gray-500">{new Date(ev.startDate).toLocaleDateString()}</span>
|
||||
</label>
|
||||
{item.on && (
|
||||
<>
|
||||
<select aria-label={`Ticket for ${ev.title}`} disabled={locked} className="border rounded px-2 py-1 text-xs" value={item.eventOptionId} onChange={e => updItem(ev, { eventOptionId: e.target.value, variantId: "" })}>
|
||||
{opts.map(o => <option key={o.id} value={o.id}>{o.name} (R{Number(o.price).toFixed(0)})</option>)}
|
||||
</select>
|
||||
{variants.length > 0 && (
|
||||
<select aria-label={`Variant for ${ev.title}`} disabled={locked} className="border rounded px-2 py-1 text-xs" value={item.variantId} onChange={e => updItem(ev, { variantId: e.target.value })}>
|
||||
<option value="">Any / none</option>
|
||||
{variants.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!locked && normalValue > 0 && (
|
||||
<p className="text-[11px] text-gray-500 mt-1">Bought separately: {rand(normalValue)}{draft.price !== "" && Number(draft.price) < normalValue ? ` — the pass saves ${rand(normalValue - Number(draft.price))}` : ""}. The price is split over the events in proportion to these prices for each event's cashup.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3 items-end">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-sale-ends">Stop selling at (optional)</label>
|
||||
<input id="pass-sale-ends" type="datetime-local" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.saleEndsAt} onChange={e => setDraft({ ...draft, saleEndsAt: e.target.value })} />
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">It always stops when the first event on it starts.</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.isActive} onChange={e => setDraft({ ...draft, isActive: e.target.checked })} /> On sale</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={save} disabled={saving || !loaded || !draft.name.trim() || (!locked && draft.price === "")} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{saving ? "Saving…" : draft.id ? "Save pass" : "Create pass"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,17 +52,21 @@ export function LicenceBanner() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Once an update has been requested there's nothing more for the admin to do. "Not now" hides it
|
||||
// until the next version, except for urgent (security or serious bug) updates.
|
||||
if (status.urgentUpdate && !status.updateRequest) {
|
||||
// Once the latest version has been requested there's nothing more for the admin to do. A request
|
||||
// for a version that has since been overtaken shows again, to change it. "Not now" hides it until
|
||||
// the next version, except for urgent (security or serious bug) updates.
|
||||
const request = status.updateRequest;
|
||||
const awaiting = !!request && !request.outdated;
|
||||
const ask = request?.outdated ? <>You asked for {request.version}; change your request in {link}</> : <>Please request the update in {link}</>;
|
||||
if (status.urgentUpdate && !awaiting) {
|
||||
return (
|
||||
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0" />
|
||||
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). Please request the update in {link}</span>
|
||||
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). {ask}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.updateAvailable && !status.updateRequest && !status.updateSnoozed) {
|
||||
if (status.updateAvailable && !awaiting && !status.updateSnoozed) {
|
||||
const notNow = async () => {
|
||||
setBusy(true);
|
||||
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
|
||||
@@ -72,7 +76,7 @@ export function LicenceBanner() {
|
||||
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="w-4 h-4 shrink-0" />
|
||||
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). See what's new and request it in {link}</span>
|
||||
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). {request?.outdated ? <>You asked for {request.version}; see what's new and change your request in {link}</> : <>See what's new and request it in {link}</>}</span>
|
||||
</span>
|
||||
<button onClick={notNow} disabled={busy} className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-brand-700 hover:bg-brand-100 disabled:opacity-50" title="Hide this until the next version comes out">
|
||||
<BellOff className="w-3.5 h-3.5" /> Not now
|
||||
|
||||
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series' registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Layers, Calendar, Bell, MapPin, Copy } from "lucide-react";
|
||||
import { Layers, Calendar, Bell, MapPin, Copy, BarChart3, Ticket } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -28,6 +28,12 @@ export const seriesHelpContent: HelpContent = {
|
||||
<GuideItem icon={Bell} title="Following" tone="amber">
|
||||
People with an account can follow a series on its page. When a new event in the series goes live (and before it starts) followers get one email. It counts as <strong>News & announcements</strong>, so only people who chose to get news receive it, with an unsubscribe link.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart3} title="Year on year" tone="green">
|
||||
"Compare" opens Reports → Series: year on year — each event in the series side by side (registrations, tickets, income, how many came) with how much it went up or down from the one before.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Season passes" tone="violet">
|
||||
"+ New pass" under a series: a name, a price and which events (and which ticket at each) it includes. People buy it on the series page and are registered for every event at once with one payment. The price is shared out over the events in line with their normal ticket prices, so each event's cashup shows its part. A pass stops selling when its first event starts; once someone has bought it, its events and price can't change (switch it off and make a new one). Youth events can't be on a pass.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MapPin} title="Campuses" tone="gray">
|
||||
A series can have events at different campuses. Supervisors limited to some campuses see the series, but only their campuses' events in it.
|
||||
</GuideItem>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -147,6 +147,27 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
label: "Webhooks",
|
||||
icon: Webhook,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Send what happens here to another system as it happens — for example to ChurchSuite, Planning Center or a Google Sheet through Zapier or Make.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Webhook} title="Add a webhook" tone="brand">
|
||||
"+ Add webhook": a name, the https address the other system gave you, and what to send — registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, published events. Copy the signing secret it shows (once) into the other system so it can check requests really come from you.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Test and delivery log" tone="green">
|
||||
"Send test" sends a test message straight away. The delivery log shows everything sent in the last 30 days, what came back, and the full message. Anything that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours; "Resend" tries again now.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Scale} title="Privacy" tone="amber">
|
||||
Webhooks send names, email addresses and phone numbers, so each webhook is added to your operator register (Admin → Privacy) — have an agreement with the service. Children's details from kids check-in are never sent. Switch a webhook off or delete it at any time.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "jobs",
|
||||
label: "Background jobs",
|
||||
@@ -184,7 +205,7 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
|
||||
When a new version is out, the Version box shows what's new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there's nothing to run yourself. Changed your mind? Withdraw request tells your provider not to.
|
||||
When a new version is out, the Version box shows what's new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there's nothing to run yourself. Changed your mind? Withdraw request tells your provider not to. If a newer version comes out before your provider has updated the site, the page shows which version you asked for and offers "Ask for … instead".
|
||||
</GuideItem>
|
||||
<GuideItem icon={BellOff} title="Not now" tone="gray">
|
||||
Not now (on the Licence page or the update banner) hides the reminder until the next version comes out. Updates that fix a security problem or a serious bug can't be put off.
|
||||
|
||||
@@ -203,6 +203,37 @@ export type EventSeries = EventSeriesRef & {
|
||||
events: { id: string; title: string; startDate: string; endDate: string; isActive: boolean; isHidden: boolean; campus?: Event["campus"] }[];
|
||||
};
|
||||
|
||||
/** A season pass (1.47.3): one price for a ticket at each of several events in a series. */
|
||||
export type SeasonPassItemView = {
|
||||
id: string; eventId: string; eventOptionId: string; variantId: string | null;
|
||||
eventTitle: string; startDate: string; endDate: string; ticket: string;
|
||||
// The ticket's normal price, and this event's share of the pass price.
|
||||
normalPrice: number; share: number;
|
||||
};
|
||||
export type SeasonPassView = {
|
||||
id: string; seriesId: string; name: string; description: string | null; price: number;
|
||||
isActive: boolean; saleEndsAt: string | null; normalValue: number; saving: number;
|
||||
// Why it can't be bought right now, or null.
|
||||
unavailable: string | null;
|
||||
items: SeasonPassItemView[];
|
||||
// Supervisor list only: passes paid for and still unpaid.
|
||||
sold?: number; unpaid?: number;
|
||||
};
|
||||
export type SeasonPassPurchase = {
|
||||
id: string; quantity: number; total: number; status: "pending" | "paid" | "cancelled"; createdAt: string;
|
||||
pass: { id: string; name: string; series: { name: string; slug: string } };
|
||||
registrations: { id: string; status: string; event: { id: string; title: string; startDate: string } }[];
|
||||
};
|
||||
|
||||
/** GET /api/series/:id/compare — one row per event in the series, oldest first. */
|
||||
export type SeriesComparisonRow = {
|
||||
eventId: string; title: string; startDate: string; endDate: string; campus?: Event["campus"]; isActive: boolean;
|
||||
sessions: number; registrations: number; tickets: number; income: number; checkedIn: number; attendanceRate: number | null;
|
||||
// % up or down from the event before (null on the first, or when the one before had none).
|
||||
change: { registrations: number | null; tickets: number | null; income: number | null; checkedIn: number | null } | null;
|
||||
};
|
||||
export type SeriesComparison = { series: EventSeriesRef; events: SeriesComparisonRow[] };
|
||||
|
||||
export type EventSession = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.1",
|
||||
"version": "1.48.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -51,6 +51,8 @@ export default defineConfig({
|
||||
SCHEDULED_EMAILS_ENABLED: "false",
|
||||
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
|
||||
TWO_FACTOR_REQUIRED_ROLES: "false",
|
||||
// webhooks.spec.ts runs its own receiver on localhost.
|
||||
WEBHOOKS_ALLOW_PRIVATE: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user