Compare commits

...
12 Commits
Author SHA1 Message Date
joshua 5efb4cd6dd Merge branch 'feat/saved-filters' 2026-10-28 14:57:34 +02:00
joshuaandClaude Opus 5.5 e40803cbbc Saved filters on list pages (1.49.2)
Manage registrations, Users, Events and Reports: save the current
filters under a name, apply one with a click, star one as the default
for the page (not applied over a deep link). Per person, stored on the
server, removed when an account is anonymised. /api/saved-filters.
Migration 20261028180000_saved_filters. Completes roadmap section 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:57:34 +02:00
joshua fc393a90c1 Merge branch 'feat/bulk-actions' 2026-10-28 14:43:01 +02:00
joshuaandClaude Opus 5.5 c1211db8cf Bulk actions on registrations (1.49.1)
Manage registrations: tick registrations (or all shown) and message
them (email/WhatsApp event update), mark them paid (the balance, by
method), move them to another ticket (same tickets and QR codes,
repriced, places checked), put their people in a room or group, export
to Excel, or cancel. Each runs the same handlers as doing it one at a
time, with a result per registration. POST /api/registrations/bulk,
campus-checked per id. Migration 20261028170000_registrations_bulk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:43:00 +02:00
joshua fb42d6ac19 Merge branch 'feat/dashboard-palette' 2026-10-28 14:17:23 +02:00
joshuaandClaude Opus 5.5 2fc9e08f89 Dashboard Today and Waiting for you; Ctrl+K command palette (1.49.0)
Staff, supervisor and admin dashboards start with what's on today
(checked in vs places, children in now, volunteers here; money in today
and this week for supervisors) and, for supervisors and admins, what's
waiting for someone (unpaid registrations, consent, waitlists, cover
requests, roster gaps; for admins cashups, reconciling, privacy
requests, failed webhooks), each linking to where it's dealt with.
Ctrl+K opens a palette of pages, settings, reports and actions for the
role, with search: events, payments by amount, people and
registrations (admins). All campus-scoped. Bulk actions and saved
filters follow as 1.49.1 and 1.49.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:22 +02:00
joshua 78bcd7cc7c Merge branch 'feat/demo-seeds' 2026-10-28 14:02:56 +02:00
joshuaandClaude Opus 5.5 37731a4696 Four demo seeds; fix set-demo wiping the admin (1.48.2)
npm run demo:small | demo:events | demo:ministry | demo:multicampus
(demo:list describes them; set-demo -- --profile=<name> in backend/).
New builders: courses with sessions, attendance and certificates, a
pick-your-days conference, kids church with families, rooms and live
check-ins, volunteer rosters, a series with followers and a season
pass, and three campuses with campus-limited staff.

set-demo had failed since 1.43.0: TRUNCATE CASCADE on Campus also
emptied User (homeCampusId), deleting the admin it keeps. Tables that
User points at are now emptied with DELETE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:02:56 +02:00
joshua 4752516b15 Merge branch 'fix/privacy-notice-draft' 2026-10-28 13:15:57 +02:00
joshuaandClaude Opus 5.5 5dea712b72 Privacy notice: built-in text up to date, review flow, acceptance (1.48.1)
The built-in notice now covers volunteer rosters, event series, season
passes, webhooks, how people agree to it, and the provider-notices
storage item. Sites with their own published version see what changed
in the editor (sections marked, use new text per section or all, add
missing sections, "Done - hide this"). Account activation asks for
agreement to the terms and notice; anyone who never accepted is asked
once after login. The cookie button shows above any cookie section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:15:57 +02:00
joshua f1714f9286 Merge branch 'feat/webhooks' 2026-10-28 13:00:59 +02:00
joshuaandClaude Opus 5.5 c0d01ce993 Outbound webhooks (1.48.0)
Site settings -> Webhooks: admins add https endpoints and choose event
types (registration created/paid/cancelled, payment received/refunded,
ticket checked in, account created, event published). Events are
noticed by a Prisma client extension so every write path is covered,
re-checked before sending, and delivered as signed JSON
(X-CrossEvents-Signature, HMAC-SHA256) with retries, a 30-day delivery
log, Send test and Resend. Public https addresses only; each endpoint
is added to the operator register; kids and volunteer tickets are never
sent. New `webhooks` job. Migration 20261028160000_webhooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:00:59 +02:00
84 changed files with 4414 additions and 109 deletions
+129
View File
@@ -7,6 +7,135 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.49.2] - 2026-10-28
### Added
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
when the page opens (but not over a link that already says what to show, such as the
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
an account is anonymised.
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
`20261028180000_saved_filters`.
## [1.49.1] - 2026-10-28
### Added
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
all shown" after filtering, and act on them at once —
- **Send a message** by email or WhatsApp, as an event update to the person who registered
(placeholders as on Email attendees);
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
their tickets by email, as with a single payment;
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
places on the new ticket are checked first;
- **Put in a room or group** (one event) — their named people, with the group's capacity and
gender rules;
- **Export to Excel** (recorded in the access log, like other exports);
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
on the Payments page).
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
## [1.49.0] - 2026-10-28
### Added
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
session today — with people checked in so far against places, children in now at kids church,
and volunteers here against rostered (and how many short). Supervisors and admins also see money
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
with — unpaid registrations for events in the next two weeks, young people waiting for parental
consent, waitlists, cover requests, serving roles in the next week short of people, and for
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
webhook deliveries.
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
`/dashboard/admin/users?q=`.
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
## [1.48.2] - 2026-10-28
### Added
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
with families, children, rooms and check-ins (including some checked in right now), volunteer
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
people. See `backend/DEMO_DATA.md`.
### Fixed
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
to them are cleared.
## [1.48.1] - 2026-10-28
### Changed
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
who sees the roster and cover requests), following an event series, season passes, webhooks
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
version show the new text straight away.
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
what changed in the built-in text since they last reviewed it, marks each section with new text
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
sections the notice doesn't have. **Done — hide this** records the review
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
"Significant change" if people should accept again) to make it live.
### Fixed
- **Agreeing to the notice when an account was made for you** — activating an account (made by
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
Privacy Policy, as sign-up does (consent log source "Account activation").
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
the built-in notice is in use. Before, they were only asked after a version marked as a
significant change was published.
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
## [1.48.0] - 2026-10-28
### Added
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
moment later, so a change that was rolled back sends nothing.
- Safety and privacy: https addresses on public networks only (checked again at each send;
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
days and with an anonymised account.
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
## [1.47.4] - 2026-10-28
### Fixed
+44 -6
View File
@@ -414,9 +414,18 @@ logout controls. Disconnected sessions are recovered automatically.
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Dashboard** — **Today** (each event or session on today: checked in so far
against places, children in now, volunteers here; money in today and this
week) and **Waiting for you** (unpaid registrations for the next two weeks,
parental consent outstanding, waitlists, cover requests, roster gaps; for
admins also cashups due, payments to reconcile, privacy requests, failed
webhooks — each links to where it's dealt with); then KPIs (active events,
revenue, donations, registrations, tickets sold, each vs. the previous month),
revenue trend chart, top events, and recent scans. Staff see Today too.
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
and reports for the role, actions ("New event", "Scan tickets"…), and search:
events; payments by amount ("R450") for supervisors; people and registrations
for admins. Follows campus scoping.
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
@@ -467,7 +476,14 @@ logout controls. Disconnected sessions are recovered automatically.
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
form answers and payments (with who recorded each one). **Bulk actions** on
ticked registrations (or all shown): message (email/WhatsApp), mark paid
(records the balance by method), move to another ticket (same tickets and QR
codes, repriced), put in a room or group, export to Excel, cancel — each
through the same rules as one at a time, with a result per registration.
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
@@ -729,8 +745,12 @@ and published, numbered versions (earlier ones stay readable with `?version=N`).
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
operator register. A version marked as a significant change is accepted once by everyone
after their next login; sign-up records the version accepted. The contact page shows the
Information Officer.
after their next login. People agree to the notice at sign-up and when they activate an account
made for them; anyone who never has is asked once after login. When the built-in text changes
in an update, a site with its own published version sees what changed in the editor, with each
changed section marked (**Use the new text**, **Use all the new text**, **Add** missing sections,
**Done — hide this**). The "Change my cookie choice" button shows above the cookie section. The
contact page shows the Information Officer.
**Registers and logs** (same admin page):
@@ -772,6 +792,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
@@ -792,6 +829,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`) |
+3
View File
@@ -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. |
+48
View File
@@ -12,6 +12,54 @@ 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.49.2
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
under a name and get back to them with one click. Star one and it's ready every time you open the
page. They're yours and follow you to any device.
## 1.49.1
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
them in a room or group, export them to Excel, or cancel them.
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
ticket, so any difference shows as owing.
- You see what happened to each one, and anything that couldn't be done says why.
## 1.49.0
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
people are checked in, children in at kids church, and volunteers here — plus money in today and
this week.
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
start "New event", or find an event, a payment by amount ("R450") or a person.
## 1.48.2
- No changes for churches in this version — it adds demo data for showing Cross Events to other
churches.
## 1.48.1
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
webhooks. If you haven't published your own version, your site shows the new text straight away.
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
built-in text, section by section, so you can take the new wording with one click and publish.
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
activate it, and anyone who never has is asked once after they next log in.
## 1.48.0
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
- Every message is signed so the other system can trust it, failures are tried again
automatically, and a delivery log shows what was sent.
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
## 1.47.4
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
+4
View File
@@ -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
+59
View File
@@ -933,6 +933,38 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Saved filters
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
else's id answers 404).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
| DELETE | `/api/saved-filters/:id` | Delete |
## Bulk actions on registrations
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
`{ action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }`. Every id is campus-checked.
| action | params | What happens |
|--------|--------|--------------|
| `markPaid` | `method`: cash/eft/card/other, `paidAt?` | A payment for what's still owed (as `POST /api/payments`); refused in licence read-only mode |
| `cancel` | — | As cancelling one registration (coupons released, waitlist offered); no refund |
| `moveOption` | `fromOptionId`, `toOptionId` (same event) | The lines, tickets and attendees move to the new ticket, repriced; status recalculated; places checked; refused in read-only mode |
| `allocate` | `setId`, `groupId` | Each named person placed, as `PUT /api/allocations/sets/:id/assignments` |
| `message` | `channel`: email/whatsapp, `subject` (email), `text` | Per event, as `POST /api/events/:id/email-attendees` / `whatsapp-attendees` with `filter.registrationIds`, kind "update" |
## Dashboard and command palette
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard/today` | staff+ | `{ events: [{ id, title, startsAt, endsAt, sessionLabel, places, checkedIn, kidsIn, volunteers }], money: { today, todayCount, week } \| null, pending: [{ key, label, count, href, tone }] }` — money and pending for supervisors/admins; campus-scoped |
| GET | `/api/dashboard/search?q=` | staff+ | `{ results: [{ type: event\|payment\|person\|registration, id, title, subtitle, href }] }` — payments by amount for supervisors, people and registrations for admins |
## Event series
| Method | Path | Auth | Description |
@@ -949,6 +981,33 @@ Events take `seriesId` (null = none) on create and update; `GET /api/events?seri
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 |
+21 -8
View File
@@ -1,10 +1,19 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
The demo seeds replace everything in the database with a realistic set of demo data, for
showing the platform to other churches. Pick the one that looks like the church you're
showing it to:
| Command (repo root) | For | What's in it |
|---|---|---|
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
`npm run demo:list` prints the same list. Kids church always has a session **today** with
children checked in right now, so the kids desk and room screens have something live to
show on any day.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
npm run demo:ministry -- --yes # no prompt (for scripts)
# or in backend/:
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
```
The campuses setting is switched on for `multicampus` and off for the others (site settings
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
+1 -1
View File
@@ -145,7 +145,7 @@ npm run prisma:generate
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.47.4",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.47.4",
"version": "1.49.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.47.4",
"version": "1.49.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,54 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'webhook_endpoint_changed';
-- CreateTable
CREATE TABLE "WebhookEndpoint" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"operatorId" TEXT,
"lastSuccessAt" TIMESTAMP(3),
"lastFailureAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"endpointId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_userId_idx" ON "WebhookDelivery"("userId");
-- CreateIndex
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "SavedFilter" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"page" TEXT NOT NULL,
"name" TEXT NOT NULL,
"params" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
-- CreateIndex
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
-- AddForeignKey
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+66
View File
@@ -164,6 +164,7 @@ model User {
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -817,6 +818,8 @@ enum AdminAuditAction {
series_updated
series_deleted
season_pass_changed
webhook_endpoint_changed
registrations_bulk
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -2249,3 +2252,66 @@ model VolunteerSwap {
@@index([roleId, status])
@@index([slotId])
}
/// Outbound webhooks (1.48.0): a URL that is sent what happens here (registrations, payments,
/// check-ins, new accounts, published events), signed with its own secret.
model WebhookEndpoint {
id String @id @default(uuid())
name String
url String
// HMAC secret, encrypted (utils/encryption.js); shown to the admin once, when made or rotated.
secret String
// Event types it gets, e.g. ["registration.paid", "ticket.checked_in"].
events String[]
isActive Boolean @default(true)
// The operator register entry made for it (POPIA), if any.
operatorId String?
lastSuccessAt DateTime?
lastFailureAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
}
/// One message to one endpoint, with its retries. Kept 30 days.
model WebhookDelivery {
id String @id @default(uuid())
endpointId String
// The event's own id (the same for every endpoint it went to) and type.
eventId String
type String
payload Json
// pending | sending | retrying | succeeded | failed
status String @default("pending")
attempts Int @default(0)
nextAttemptAt DateTime @default(now())
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
// Whose personal information is in it, so it can be removed with their account.
userId String?
createdAt DateTime @default(now())
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
@@index([status, nextAttemptAt])
@@index([endpointId, createdAt])
@@index([userId])
@@index([createdAt])
}
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
/// events, reports), `params` the filter values. At most one default per person and page.
model SavedFilter {
id String @id @default(uuid())
userId String
page String
name String
params Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, page, name])
@@index([userId, page])
}
+598 -19
View File
@@ -2,9 +2,19 @@
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
* completed events from the past year with door sales, check-ins, feedback and cashups.
* churches. Four profiles, for different kinds of church (see PROFILES below, or
* `npm run demo:list`):
*
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
* events Events and conferences (the original demo, the default): six upcoming events
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
* analytics, and ten completed events with door sales, feedback and cashups.
* ministry One campus with weekly ministry: a multi-session course (attendance and
* certificates), a "pick your days" conference, kids church with kids check-in,
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
* multicampus Three campuses: events per campus and church-wide, home campuses,
* campus-limited staff, a series across campuses, kids check-in and rosters at two
* campuses.
*
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
@@ -13,10 +23,14 @@
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
* have no phone numbers, so later reminders can't reach a real person.
*
* Usage:
* cd backend
* npm run set-demo (asks for confirmation)
* npm run set-demo -- --yes (no prompt)
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
* backend afterwards so it picks the setting up.
*
* Usage (from the repo root):
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
* npm run demo:list (what each one has)
* or in backend/:
* npm run set-demo -- --profile=ministry --yes (no prompt)
*/
'use strict';
@@ -38,6 +52,8 @@ const prisma = createPrismaClient();
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
const DEMO_PASSWORD = 'Demo@1234';
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
const LIST_ONLY = process.argv.includes('--list');
// Not truncated: migrations, site settings, users (all but the admin are deleted
// separately) and the admin's own account-security rows.
@@ -161,13 +177,16 @@ const add = (model, row) => {
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
'ticketUsage', 'couponRedemption', 'registrationBundle',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
];
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
// "Pick sessions" events: a per-session line.
sessionId: l.sessionId || null,
});
add('registrationOptionTranche', {
id: uuidv4(),
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
*/
function buildPastEvent(people, spec) {
// Each profile has its own selection of past events (`always`: built by a profile's extras).
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
const { start } = spec;
const event = addEvent({
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
title: spec.title,
description: spec.description,
startDate: start,
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
return results;
}
// ─── Profiles ────────────────────────────────────────────────────────────────
const UPCOMING = {
mensBreakfast: buildMensBreakfast,
familyFunDay: buildFamilyFunDay,
womensConference: buildWomensConference,
youthCamp: buildYouthCamp,
carols: buildCarols,
marriageWeekend: buildMarriageWeekend,
};
/**
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
* campuses: three campuses, switched on.
*/
const PROFILES = {
small: {
title: 'Small church — one congregation, the essentials',
about: [
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
'Four past events with check-ins and cashups (one left open to cash up live).',
'No campuses, kids check-in, volunteer rosters, courses or series.',
],
campuses: false,
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
extras: [],
},
events: {
title: 'Events & conferences — the full events demo (default)',
about: [
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
'One campus; no kids check-in, rosters, courses or series.',
],
campuses: false,
upcoming: Object.keys(UPCOMING),
past: null,
extras: [],
},
ministry: {
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
about: [
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
],
campuses: false,
upcoming: ['familyFunDay', 'womensConference', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
},
multicampus: {
title: 'Multi-campus church — three campuses',
about: [
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
'Kids church with kids check-in and volunteer rosters at Central and North.',
'All six upcoming and ten past events of the events demo, spread over the campuses.',
],
campuses: true,
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
past: null,
extras: ['series', 'kids', 'volunteers'],
},
};
let profile = PROFILES[PROFILE_NAME];
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
let campuses = [];
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
function buildCampuses() {
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
});
campuses = [
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
];
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
}
/** Spreads the events over the campuses; the big ones stay church-wide. */
function assignCampuses() {
const byTitle = {
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
};
let i = 0;
for (const event of rows.event) {
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
if (event.seriesId || event.kidsCheckIn) continue;
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
event.campusId = idx === null ? null : campuses[idx].id;
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
}
}
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
async function scopeStaff(tx) {
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
}
}
// ─── Event series and a season pass ──────────────────────────────────────────
function buildBreakfast(people, { start, title, sold }) {
const event = addEvent({
title,
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
for (const user of shuffle(people.men).slice(0, sold)) {
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
}
return { event, ticket };
}
/**
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
* each breakfast is at a different campus.
*/
function buildSeries(people) {
const series = add('eventSeries', {
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
});
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
const pastSpec = (title, daysAgo, speaker) => ({
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
options: [{ name: 'Breakfast ticket', price: 150 }],
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
showRate: 0.9,
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
});
const past = [
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
];
const current = buildMensBreakfast(people);
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
[...past, current, next.event, later.event].forEach((event, i) => {
event.seriesId = series.id;
// Followers were told when each came out (so the series job doesn't email demo people).
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
const campus = campusAt(i);
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
});
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
const pass = add('seasonPass', {
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
});
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
for (const user of shuffle(people.men).slice(0, 6)) {
const at = pastDate(15, 1);
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
[next, later].forEach((b, i) => {
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
add('payment', {
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
});
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
});
}
return [current, next.event, later.event];
}
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
return Array.from({ length: count }, (_, k) => {
const startsAt = addTime(first, k * everyDays * DAY);
return add('eventSession', {
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
});
});
}
/** Check-ins per session that has ended, for the event's tickets. */
function attendSessions(event, sessions, showRate) {
const attended = new Map();
for (const s of sessions.filter(x => x.endsAt < NOW)) {
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
if (!chance(showRate)) continue;
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
attended.set(t.id, (attended.get(t.id) || 0) + 1);
}
}
return attended;
}
const CERTIFICATE = (min) => ({
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
});
function buildCourses(people) {
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
const first = lastWeekday(15, 3, 18, 30);
const alpha = addEvent({
title: 'Alpha Course',
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
});
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
for (const user of shuffle(people.everyone).slice(0, 30)) {
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
}
attendSessions(alpha, alphaSessions, 0.85);
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
const mFirst = lastWeekday(80, 4, 19);
const marriage = addEvent({
title: 'The Marriage Course',
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
});
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
for (const user of shuffle(people.everyone).slice(0, 14)) {
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
}
const attended = attendSessions(marriage, mSessions, 0.88);
const issuedAt = addTime(mSessions[5].endsAt, DAY);
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
const n = attended.get(t.id) || 0;
if (n < 5) continue;
const owner = rows.user.find(u => u.id === t.userId);
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
}
return [alpha];
}
function buildLeadershipConference(people) {
const day1 = nextWeekday(40, 4, 8, 30);
const event = addEvent({
title: 'Lead Well Leadership Conference',
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
});
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
const buyers = shuffle(people.everyone);
for (const user of buyers.splice(0, 34)) {
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
}
for (const user of buyers.splice(0, 26)) {
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
}
return [event];
}
// ─── Kids church with kids check-in ──────────────────────────────────────────
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
const inFamily = new Set();
const kidsChurches = [];
function ageAt(dob, at) {
let age = at.getFullYear() - dob.getFullYear();
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
return Math.max(0, age);
}
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
function kidsCode(taken) {
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
for (;;) {
let c = '';
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
if (!taken.has(c)) { taken.add(c); return c; }
}
}
/**
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
*/
function buildKidsChurch(people, campus = null) {
const { encrypt } = require('../src/utils/encryption');
const first = lastWeekday(28, 0, 9, 0);
const event = addEvent({
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
});
const sessions = addSessions(event, first, 12, { minutes: 90 });
// A session today, so the desk and room screens have children in right now whatever day the
// demo is shown (on a Sunday it's that Sunday's session).
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
if (!live) {
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
sessions.push(live);
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
}
const option = addOption(event, { name: 'Kids check-in', price: 0 });
event.kidsOptionId = option.id;
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
event.kidsRoomSetId = set.id;
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
const families = parents.map(parent => {
inFamily.add(parent.id);
const { last } = userMeta.get(parent.id);
const createdAt = pastDate(200, 40);
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
if (chance(0.5)) {
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
}
const children = Array.from({ length: int(1, 3) }, () => {
const girl = chance(0.5);
const dob = new Date(NOW);
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
dob.setHours(0, 0, 0, 0);
return add('child', {
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
lastCheckInAt: null, createdAt, updatedAt: NOW,
});
});
return { parent, family, children };
});
const held = sessions.filter(s => s.endsAt < NOW);
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
let liveFamilies = 0;
for (const f of families) {
const attending = held.filter(() => chance(0.75));
// About half the families are in right now (checked in, not yet collected).
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
if (inNow) { attending.push(live); liveFamilies++; }
if (!attending.length) continue;
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
const attendees = regLines[0].attendees;
for (const s of attending) {
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
const isLive = s === live;
f.children.forEach((c, i) => {
if (!chance(0.92)) return;
const at = isLive
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
const desk = pick(staffTeam);
add('kidsCheckIn', {
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
checkedInAt: at, checkedInById: desk.id,
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
checkedOutById: isLive ? null : pick(staffTeam).id,
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
});
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
});
}
}
for (const s of [...held, live]) {
const isLive = s === live;
if (isLive && live.startsAt > NOW) continue;
for (const room of rooms) {
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
add('kidsRoomDuty', {
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
});
}
}
}
kidsChurches.push({ event, sessions, rooms });
return [event];
}
// ─── Volunteer rosters ───────────────────────────────────────────────────────
/**
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
* marked as sent, so the reminder job doesn't email demo people.
*/
function addRoster(people, event, { session = null, roles, rooms = [] }) {
const start = session ? session.startsAt : event.startDate;
const held = (session ? session.endsAt : event.endDate) < NOW;
const daysAway = (start - NOW) / DAY;
const pool = shuffle(people.everyone);
const slots = [];
roles.forEach(([name, needed, roomName], order) => {
const role = add('servingRole', {
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
});
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
for (const user of pool.splice(0, filled)) {
const checked = held && chance(0.9);
// Most scan the QR code themselves; now and then a staff member ticks them in.
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
slots.push(add('volunteerSlot', {
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
}));
}
});
return slots;
}
function buildVolunteers(people, built) {
for (const { event, sessions, rooms } of kidsChurches) {
event.volunteerCheckInToken = hex();
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
coming.forEach((session, i) => {
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
// Someone can't make the next Sunday and has asked for cover.
if (i === 0 && slots.length) {
const slot = slots[0];
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
}
});
}
const dayEvent = built.find(e => e.title === 'Family Fun Day');
if (dayEvent) {
dayEvent.volunteerCheckInToken = hex();
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
}
}
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
function buildExtras(people, built) {
const out = [];
const has = (x) => profile.extras.includes(x);
if (has('series')) out.push(...buildSeries(people));
if (has('courses')) out.push(...buildCourses(people));
if (has('conference')) out.push(...buildLeadershipConference(people));
if (has('kids')) {
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
else out.push(...buildKidsChurch(people));
}
if (has('volunteers')) buildVolunteers(people, built.concat(out));
return out;
}
function listProfiles() {
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
for (const [name, p] of Object.entries(PROFILES)) {
console.log(` ${name.padEnd(12)} ${p.title}`);
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
console.log('');
}
}
// ─── Funnel analytics ────────────────────────────────────────────────────────
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
for (const event of rows.event) {
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
for (const event of rows.event.filter(e => !e.isHidden)) {
const raw = [];
const session = (stages, at) => {
const sessionId = uuidv4();
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
// after the admin's links to them are cleared.
const userRefs = await tx.$queryRaw`
SELECT DISTINCT ref.relname AS table, att.attname AS column
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_class ref ON ref.oid = c.confrelid
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
const deleteInstead = new Set(userRefs.map(r => r.table));
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
if (userRefs.length) {
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
}
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
@@ -1269,6 +1827,7 @@ function describeDatabase() {
}
async function confirm(admin) {
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
if (SKIP_PROMPT) return true;
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
}
async function main() {
if (LIST_ONLY) { listProfiles(); return; }
if (!profile) {
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
listProfiles();
process.exitCode = 1;
return;
}
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
if (!admin) {
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
@@ -1295,15 +1861,11 @@ async function main() {
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
if (profile.campuses) buildCampuses();
const events = profile.upcoming.map(key => UPCOMING[key](people));
events.push(...buildExtras(people, events));
buildPastEvents(people);
if (profile.campuses) assignCampuses();
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
@@ -1311,6 +1873,13 @@ async function main() {
for (const model of INSERT_ORDER) {
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
}
if (profile.campuses) await scopeStaff(tx);
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
await tx.appSetting.upsert({
where: { key: 'campuses_enabled' },
update: { value: profile.campuses ? 'true' : 'false' },
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
});
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
@@ -1348,7 +1917,17 @@ async function main() {
].filter(Boolean).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
const extras = [
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
count('eventSession') && `${count('eventSession')} sessions`,
count('certificate') && `${count('certificate')} certificates`,
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
].filter(Boolean);
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
console.log(`\nKept: site settings and ${admin.email}.`);
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
+81 -1
View File
@@ -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;
@@ -1602,6 +1602,9 @@ const emailEventAttendees = async (req, res) => {
// Prefer server-side filtering when possible
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
// Name/email substring filter (kept for backward compatibility)
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
@@ -1870,6 +1873,9 @@ const whatsappEventAttendees = async (req, res) => {
if (attendeeIds.length > 0) {
where.userId = { in: attendeeIds };
}
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
if (registrationIds.length > 0) where.id = { in: registrationIds };
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
@@ -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 };
@@ -65,12 +65,30 @@ const getNoticeDraft = async (req, res) => {
placeholders: notice.PLACEHOLDERS,
versions: await versionList(),
builtIn: notice.defaultSections(),
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
builtInUpdates: await notice.builtInUpdates(),
});
} catch (error) {
fail(res, error);
}
};
// @desc The built-in text changes have been gone through (the editor stops listing them)
// @route POST /api/privacy/notice/builtin-reviewed
// @access Private/Admin
const markBuiltInReviewed = async (req, res) => {
try {
await notice.markBuiltInReviewed();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
});
res.json({ builtInUpdates: [] });
} catch (error) {
fail(res, error);
}
};
// @desc Save the draft (body.sections)
// @route PUT /api/privacy/notice/draft
// @access Private/Admin
@@ -425,6 +443,7 @@ const updateBreach = async (req, res) => {
};
module.exports = {
markBuiltInReviewed,
getReadiness,
getRetention,
saveRetention,
@@ -1114,6 +1114,13 @@ const activateAccount = async (req, res) => {
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
});
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
// Without it (an older page) they're asked once after login instead.
if (req.body.acceptTerms === true) {
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
}
// Send welcome email (skip guest/placeholder accounts)
if (updated.email && !updated.email.endsWith('@guest.local')) {
sendWelcomeEmail(updated).catch(() => {});
+30
View File
@@ -187,6 +187,9 @@ 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/dashboard', require('./routes/dashboardRoutes'));
app.use('/api/saved-filters', require('./routes/savedFilterRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -680,6 +683,8 @@ app.get('/docs', async (req, res) => {
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/bulk', auth:'supervisor+', desc:'Bulk actions on ticked registrations (up to 500), each run through the same code as doing it one at a time: action "markPaid" { method: cash|eft|card|other, paidAt? } records the balance; "cancel"; "moveOption" { fromOptionId, toOptionId } (same event); "allocate" { setId, groupId } places their named people; "message" { channel: email|whatsapp, subject?, text } sends an event update. Answers { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }. Campus-checked for every id; payments and ticket changes refused in licence read-only mode. Audited as registrations_bulk',
request:{ body:{ action:'markPaid', registrationIds:['reg-uuid-1','reg-uuid-2'], method:'cash' } } },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -1011,6 +1016,31 @@ app.get('/docs', async (req, res) => {
{ 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: 'Saved filters', base: '/api/saved-filters', endpoints: [
{ method:'GET', path:'/api/saved-filters?page=', auth:'staff+', desc:'My saved filters for a list page (registrations, users, events, reports), default first' },
{ method:'POST', path:'/api/saved-filters', auth:'staff+', desc:'Save (or replace by name) a filter: { page, name, params, isDefault? }. Up to 30 per page; making one the default clears the old default',
request:{ body:{ page:'registrations', name:'Camp — unpaid', params:{ event:'ev-uuid', status:'pending' }, isDefault:false } } },
{ method:'PUT', path:'/api/saved-filters/:id', auth:'staff+', desc:'Rename it, or make it (or stop it being) my default — my own only' },
{ method:'DELETE', path:'/api/saved-filters/:id', auth:'staff+', desc:'Delete one of my saved filters' },
]},
{ title: 'Dashboard and command palette', base: '/api/dashboard', endpoints: [
{ method:'GET', path:'/api/dashboard/today', auth:'staff+', desc:'Events on today (or with a session today): places, people checked in today, children in now, volunteers rostered/checked in. Supervisors and admins also get money in (today, last 7 days) and "waiting" items: unpaid registrations for the next two weeks, parental consent, waitlist, cover requests, roster gaps, and for admins cashups due, provider payments to reconcile, privacy requests, failed webhooks. Campus-scoped' },
{ method:'GET', path:'/api/dashboard/search?q=', auth:'staff+', desc:'Command palette search: events; supervisors also payments by amount ("R450"); admins also people and registrations by reference. { results: [{ type, id, title, subtitle, href }] }' },
]},
{ 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',
+4
View File
@@ -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(),
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const dashboard = require('../utils/dashboard');
// Both follow the person's campus scope inside utils/dashboard.js.
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
// @route GET /api/dashboard/today
// @access Private/Staff (money and waiting items for supervisors and admins)
router.get('/today', protect, staff, scopeList, async (req, res) => {
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
});
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
// @route GET /api/dashboard/search
// @access Private/Staff
router.get('/search', protect, staff, scopeList, async (req, res) => {
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
});
module.exports = router;
@@ -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;
+2
View File
@@ -21,6 +21,7 @@ const {
saveNoticeDraft,
discardNoticeDraft,
publishNotice,
markBuiltInReviewed,
listOperators,
createOperator,
updateOperator,
@@ -64,6 +65,7 @@ router.get('/notice/draft', protect, admin, getNoticeDraft);
router.put('/notice/draft', protect, admin, saveNoticeDraft);
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
router.post('/notice/publish', protect, admin, publishNotice);
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
// Admin: operator register, consent log, access log
router.get('/operators', protect, admin, listOperators);
+5 -1
View File
@@ -17,7 +17,7 @@ const {
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
const { notOnSeasonPass } = require('../utils/seasonPass');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
@@ -46,5 +46,9 @@ router.get('/', protect, staff, scopeList, getRegistrations);
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const filters = require('../utils/savedFilters');
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
router.get('/', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
});
router.post('/', protect, staff, OWN, async (req, res) => {
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.put('/:id', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.delete('/:id', protect, staff, OWN, async (req, res) => {
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
});
module.exports = router;
+192
View File
@@ -0,0 +1,192 @@
/**
* Bulk actions on registrations (1.49.1): the registrations list lets a supervisor or admin tick
* many and act on them at once — mark paid, cancel, move to another ticket, put in a room or
* group, or send a message.
*
* Each action runs the same code as doing it one at a time (the controllers' own handlers, called
* in-process), so every rule still applies: closed events, payment caps and overpayments becoming
* donations, tickets and emails on payment, coupons released and the waitlist offered on
* cancelling, pricing on a ticket change, room capacity and gender, and the privacy rules and
* footers on messages. Results come back per registration; one failing doesn't stop the rest.
*/
const prisma = require('../config/db');
const MAX = 500;
const ACTIONS = ['markPaid', 'cancel', 'moveOption', 'allocate', 'message'];
const METHODS = ['cash', 'eft', 'card', 'other'];
function bulkError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Calls an Express handler in-process; resolves to { ok, status, body }. */
function call(handler, req) {
return new Promise((resolve) => {
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
send(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
};
Promise.resolve(handler({ query: {}, params: {}, body: {}, headers: {}, ip: req.ip, ...req }, res))
.catch(e => resolve({ ok: false, status: 500, body: { message: e?.message || String(e) } }));
});
}
/** Who is asking, with their request's headers and socket (so audit entries keep the real IP). */
const from = req => ({ user: req.user, headers: req.headers || {}, socket: req.socket });
const messageOf = r => r.body?.message || (r.ok ? 'Done' : `Failed (${r.status})`);
async function markPaid(req, regs, { method, paidAt }) {
if (!METHODS.includes(method)) throw bulkError('Choose how it was paid (cash, EFT, card or other)');
const { createPayment } = require('../controllers/paymentController');
const { refreshPricingForRegistration, computeRegistrationTotalDue } = require('./pricing');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled — not paid' }); continue; }
try { await refreshPricingForRegistration(r.id); } catch { /* the payment code refreshes too */ }
const fresh = await prisma.registration.findUnique({ where: { id: r.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } });
const owed = Math.round((computeRegistrationTotalDue(fresh, new Date()) - fresh.payments.reduce((s, p) => s + p.amount, 0)) * 100) / 100;
if (owed <= 0) { out.push({ id: r.id, ok: true, skipped: true, message: 'Already paid' }); continue; }
const res = await call(createPayment, { ...from(req), body: { amount: owed, method, registrationId: r.id, userId: r.userId, ...(paidAt ? { paidAt } : {}) } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? `R${owed.toFixed(2)} recorded` : messageOf(res) });
}
return out;
}
async function cancel(req, regs) {
const { cancelRegistration } = require('../controllers/registrationController');
const out = [];
for (const r of regs) {
if (r.status === 'cancelled') { out.push({ id: r.id, ok: true, skipped: true, message: 'Already cancelled' }); continue; }
const res = await call(cancelRegistration, { ...from(req), params: { id: r.id } });
out.push({ id: r.id, ok: res.ok, message: res.ok ? 'Cancelled' : messageOf(res) });
}
return out;
}
/**
* Moves every place on `fromOptionId` to `toOptionId`, keeping the same lines, tickets (and their
* QR codes) and named people — the editing screen can't take issued tickets away, but a move
* doesn't need to. Each moved line is repriced at the new ticket's current price (early birds and
* staff prices as for a new registration); the status follows what's now paid against owed, so
* moving up shows the difference owing and moving down shows the overpayment. Places on the new
* ticket are checked first.
*/
async function moveOption(req, regs, { fromOptionId, toOptionId }) {
if (!fromOptionId || !toOptionId || fromOptionId === toOptionId) throw bulkError('Choose the ticket to move from and the one to move to');
const options = await prisma.eventOption.findMany({ where: { id: { in: [fromOptionId, toOptionId] } }, include: { earlyBirdTiers: true, event: { select: { id: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true } } } });
if (options.length !== 2 || options[0].eventId !== options[1].eventId) throw bulkError('Both tickets must belong to the same event');
const target = options.find(o => o.id === toOptionId);
const event = target.event;
if (event.cashupStatus === 'closed') throw bulkError('This event is closed. Reopen it (admin only) before making changes.');
if ([event.kidsOptionId, event.volunteerOptionId].includes(toOptionId)) throw bulkError('The kids and volunteer tickets are handed out by staff, not moved to');
const { resolveOptionPrice } = require('./pricing');
const { remainingFor } = require('./stock');
const { updateRegistrationStatus } = require('../payments/webhook');
const out = [];
for (const r of regs) {
if (r.eventId !== event.id) { out.push({ id: r.id, ok: false, message: 'Not for this event' }); continue; }
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled' }); continue; }
if (r.seasonPassPurchaseId) { out.push({ id: r.id, ok: false, message: 'Part of a season pass — tickets can\'t be changed' }); continue; }
const lines = await prisma.registrationOption.findMany({ where: { registrationId: r.id, eventOptionId: fromOptionId, sessionId: null }, select: { id: true, quantity: true } });
if (!lines.length) { out.push({ id: r.id, ok: true, skipped: true, message: 'No such ticket to move' }); continue; }
const qty = lines.reduce((s, l) => s + l.quantity, 0);
const left = await remainingFor(target, null);
if (left !== null && left < qty) { out.push({ id: r.id, ok: false, message: `Only ${left} place${left === 1 ? '' : 's'} left on ${target.name}` }); continue; }
try {
const owner = await prisma.user.findUnique({ where: { id: r.userId }, select: { role: true } });
const { price, tierId } = await resolveOptionPrice(target, qty, owner);
await prisma.$transaction(async tx => {
for (const line of lines) {
await tx.registrationOption.update({ where: { id: line.id }, data: { eventOptionId: toOptionId, variantId: null, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.registrationOptionTranche.deleteMany({ where: { registrationOptionId: line.id } });
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: line.quantity, priceSnapshot: price, appliedTierId: tierId || null } });
await tx.attendee.updateMany({ where: { registrationOptionId: line.id }, data: { eventOptionId: toOptionId, variantId: null } });
}
});
const updated = await updateRegistrationStatus(r.id);
const diff = Math.round(((updated.totalDue || 0) - (updated.totalPaid || 0)) * 100) / 100;
out.push({ id: r.id, ok: true, message: diff > 0 ? `Moved — R${diff.toFixed(2)} now owing` : diff < 0 ? `Moved — R${(-diff).toFixed(2)} overpaid` : 'Moved' });
} catch (e) {
out.push({ id: r.id, ok: false, message: e?.message || 'Couldn\'t move' });
}
}
return out;
}
async function allocate(req, regs, { setId, groupId }) {
if (!setId || !groupId) throw bulkError('Choose the group to put them in');
const { assign } = require('../controllers/allocationController');
const out = [];
for (const r of regs) {
const people = await prisma.attendee.findMany({ where: { registrationId: r.id }, select: { id: true, name: true } });
if (!people.length) { out.push({ id: r.id, ok: false, message: 'No named people on this registration to place' }); continue; }
const fails = [];
for (const a of people) {
const res = await call(assign, { ...from(req), params: { id: setId }, body: { attendeeId: a.id, groupId } });
if (!res.ok) fails.push(`${a.name}: ${messageOf(res)}`);
}
out.push({ id: r.id, ok: fails.length === 0, message: fails.length ? fails.join('; ') : `${people.length} placed` });
}
return out;
}
async function message(req, regs, { channel, subject, text }) {
if (!['email', 'whatsapp'].includes(channel)) throw bulkError('Choose email or WhatsApp');
if (!String(text || '').trim()) throw bulkError('Write the message');
if (channel === 'email' && !String(subject || '').trim()) throw bulkError('Give the email a subject');
const { emailEventAttendees, whatsappEventAttendees } = require('../controllers/eventController');
const byEvent = new Map();
for (const r of regs) byEvent.set(r.eventId, [...(byEvent.get(r.eventId) || []), r.id]);
const out = [];
for (const [eventId, ids] of byEvent) {
// An event update to the people ticked (they registered, so it goes even to those who opted out of news).
const body = channel === 'email'
? { subject, text, kind: 'update', filter: { registrationIds: ids } }
: { message: text, kind: 'update', filter: { registrationIds: ids } };
const res = await call(channel === 'email' ? emailEventAttendees : whatsappEventAttendees, { ...from(req), params: { id: eventId }, body });
for (const id of ids) out.push({ id, ok: res.ok, message: res.ok ? `Sent with ${res.body?.sent ?? res.body?.matched ?? ids.length} for this event` : messageOf(res) });
}
return out;
}
/**
* Runs one bulk action. Returns { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }.
*/
async function runBulk(req, { action, registrationIds, ...params }) {
if (!ACTIONS.includes(action)) throw bulkError('Unknown bulk action');
const ids = [...new Set((Array.isArray(registrationIds) ? registrationIds : []).map(String))];
if (!ids.length) throw bulkError('Tick at least one registration');
if (ids.length > MAX) throw bulkError(`At most ${MAX} at a time`);
// Read-only licence: no new payments or ticket changes (cancelling and messaging still work).
if (['markPaid', 'moveOption'].includes(action)) {
const { licenceStatus } = require('../hub/licence');
const status = await licenceStatus().catch(() => null);
if (status?.readOnly) throw bulkError('This site is read-only (licence), so payments and ticket changes can\'t be recorded.', 403);
}
const regs = await prisma.registration.findMany({ where: { id: { in: ids } }, include: { registrationOptions: { select: { eventOptionId: true, variantId: true, quantity: true, sessionId: true } } } });
const missing = ids.filter(id => !regs.some(r => r.id === id)).map(id => ({ id, ok: false, message: 'Not found' }));
const run = { markPaid, cancel, moveOption, allocate, message }[action];
const results = [...missing, ...(await run(req, regs, params))];
const { logAdminAction } = require('./adminAudit');
const { getClientIp } = require('./requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'registrations_bulk', targetType: 'Registration',
metadata: { bulk: action, registrations: ids.length, done: results.filter(r => r.ok && !r.skipped).length, failed: results.filter(r => !r.ok).length },
ip: getClientIp({ headers: {}, ...req }),
});
return {
action,
done: results.filter(r => r.ok && !r.skipped).length,
skipped: results.filter(r => r.skipped).length,
failed: results.filter(r => !r.ok).length,
results,
};
}
module.exports = { runBulk, ACTIONS, MAX };
+249
View File
@@ -0,0 +1,249 @@
/**
* The home dashboard for staff, supervisors and admins (1.49.0): what's on today, money in, and
* what's waiting for someone — plus the search behind the Ctrl+K command palette.
*
* Everything follows the person's campus scope (utils/campusScope.js): a campus-limited
* supervisor sees their campuses' events and church-wide ones. Items only some roles can act on
* (cashups, provider payments, privacy requests, webhooks) are shown to those roles only.
*/
const prisma = require('../config/db');
const { scopeFor, eventWhere } = require('./campusScope');
const DAY = 864e5;
// The same "real money" as the dashboards: everything except legs that apply an earlier donation.
const EXCLUDE_DONATION_LEGS = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
function dayBounds(now = new Date()) {
const start = new Date(now); start.setHours(0, 0, 0, 0);
return { start, end: new Date(start.getTime() + DAY - 1) };
}
const isAdmin = req => req.user?.role === 'admin';
const isSupervisor = req => ['admin', 'supervisor'].includes(req.user?.role);
/** Events on today (single-date events running today, or a session today), with how it's going. */
async function todaysEvents(scopeWhere, now = new Date()) {
const { start, end } = dayBounds(now);
const events = await prisma.event.findMany({
where: {
AND: [scopeWhere, { isActive: true }, {
OR: [
{ scheduleType: 'single', startDate: { lte: end }, endDate: { gte: start } },
{ scheduleType: 'sessions', sessions: { some: { startsAt: { gte: start, lte: end }, cancelledAt: null } } },
],
}],
},
orderBy: { startDate: 'asc' },
select: {
id: true, title: true, startDate: true, endDate: true, location: true, scheduleType: true, kidsCheckIn: true, isHidden: true,
campus: { select: { id: true, name: true, slug: true, colour: true } },
sessions: { where: { startsAt: { gte: start, lte: end }, cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
},
});
return Promise.all(events.map(async e => {
const session = e.sessions[0] || null;
const [places, scans, kidsIn, roles] = await Promise.all([
prisma.ticket.aggregate({ where: { eventId: e.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }),
prisma.ticketUsage.aggregate({ where: { ticket: { eventId: e.id }, scannedAt: { gte: start, lte: end } }, _sum: { quantityRedeemed: true } }),
e.kidsCheckIn ? prisma.kidsCheckIn.count({ where: { eventId: e.id, checkedOutAt: null, checkedInAt: { gte: start, lte: end } } }) : Promise.resolve(null),
prisma.servingRole.findMany({
where: { eventId: e.id, OR: [{ sessionId: null }, ...(session ? [{ sessionId: session.id }] : [])] },
select: { needed: true, slots: { select: { checkedInAt: true } } },
}),
]);
return {
id: e.id, title: e.title, location: e.location, campus: e.campus, isHidden: e.isHidden, kidsCheckIn: e.kidsCheckIn,
startsAt: session ? session.startsAt : e.startDate, endsAt: session ? session.endsAt : e.endDate,
sessionLabel: session ? session.label : null,
places: places._sum.quantity || 0,
checkedIn: scans._sum.quantityRedeemed || 0,
kidsIn,
volunteers: roles.length ? {
needed: roles.reduce((s, r) => s + r.needed, 0),
rostered: roles.reduce((s, r) => s + r.slots.length, 0),
checkedIn: roles.reduce((s, r) => s + r.slots.filter(x => x.checkedInAt).length, 0),
} : null,
};
}));
}
/** Money in today and over the last 7 days (the person's events, or everything for the unscoped). */
async function moneyIn(scope, now = new Date()) {
const { start } = dayBounds(now);
const weekStart = new Date(start.getTime() - 6 * DAY);
const where = from => ({ ...EXCLUDE_DONATION_LEGS, createdAt: { gte: from }, ...(scope ? { event: eventWhere(scope) } : {}) });
const [today, week] = await Promise.all([
prisma.payment.aggregate({ where: where(start), _sum: { amount: true }, _count: { _all: true } }),
prisma.payment.aggregate({ where: where(weekStart), _sum: { amount: true } }),
]);
return { today: today._sum.amount || 0, todayCount: today._count._all, week: week._sum.amount || 0 };
}
/**
* What's waiting for someone: each { key, label, count, href, tone }, only those with a count.
* `href` takes the person to where it's dealt with.
*/
async function pendingItems(req, scope, now = new Date()) {
const upcoming = { startDate: { gte: now } };
const scoped = (extra = {}) => ({ AND: [eventWhere(scope), extra] });
const soon = new Date(now.getTime() + 14 * DAY);
const week = new Date(now.getTime() + 7 * DAY);
const items = [];
const push = async (key, fn) => {
try {
const item = await fn();
if (item && item.count > 0) items.push({ key, ...item });
} catch (e) {
console.warn(`[dashboard] ${key}:`, e?.message || e);
}
};
await Promise.all([
push('unpaid', async () => ({
label: 'Unpaid or part-paid registrations for events in the next two weeks',
count: await prisma.registration.count({ where: { status: { in: ['pending', 'partial_paid'] }, event: scoped({ startDate: { gte: now, lte: soon }, isActive: true }) } }),
href: isAdmin(req) ? '/dashboard/admin/registrations?status=pending' : '/dashboard/supervisor/reports?report=regStatus',
tone: 'amber',
})),
push('consent', async () => {
const pending = await prisma.consentRequest.findMany({ where: { status: 'pending', event: scoped(upcoming) }, select: { attendeeId: true }, distinct: ['attendeeId'] });
const given = new Set((await prisma.consentRequest.findMany({ where: { status: 'given', attendeeId: { in: pending.map(p => p.attendeeId) } }, select: { attendeeId: true } })).map(g => g.attendeeId));
return { label: 'Young people still waiting for parental consent', count: pending.filter(p => !given.has(p.attendeeId)).length, href: '/dashboard/supervisor/consent', tone: 'rose' };
}),
push('waitlist', async () => ({
label: 'People on a waitlist',
count: await prisma.waitlistEntry.count({ where: { status: 'waiting', event: scoped(upcoming) } }),
href: '/dashboard/supervisor/waitlist',
tone: 'blue',
})),
push('cover', async () => ({
label: 'Volunteers asking for someone to cover their shift',
count: await prisma.volunteerSwap.count({ where: { status: 'open', role: { event: scoped({ endDate: { gte: now } }) } } }),
href: '/dashboard/supervisor/volunteers',
tone: 'violet',
})),
push('roster', async () => {
const roles = await prisma.servingRole.findMany({
where: {
event: scoped({ isActive: true }),
OR: [{ session: { startsAt: { gte: now, lte: week } } }, { sessionId: null, event: { startDate: { gte: now, lte: week } } }],
},
select: { needed: true, _count: { select: { slots: true } } },
});
return { label: 'Serving roles in the next week that still need people', count: roles.filter(r => r._count.slots < r.needed).length, href: '/dashboard/supervisor/volunteers', tone: 'violet' };
}),
isAdmin(req) && push('cashup', async () => ({
label: 'Past events still to cash up',
count: await prisma.event.count({ where: { cashupStatus: 'open', endDate: { lt: new Date(now.getTime() - DAY), gte: new Date(now.getTime() - 120 * DAY) }, payments: { some: {} } } }),
href: '/dashboard/admin/cashup',
tone: 'green',
})),
(isAdmin(req) || (isSupervisor(req) && !scope)) && push('reconcile', async () => ({
label: 'Online payments that didn\'t match a registration (reconcile them)',
count: await prisma.providerTransaction.count({ where: { reconciled: false, ignored: false } }),
href: '/dashboard/supervisor/payments',
tone: 'amber',
})),
isAdmin(req) && push('privacy', async () => ({
label: 'Privacy requests to answer',
count: await prisma.dataRequest.count({ where: { status: 'open' } }),
href: '/dashboard/admin/data-requests',
tone: 'rose',
})),
isAdmin(req) && push('webhooks', async () => ({
label: 'Webhook deliveries that failed this week',
count: await prisma.webhookDelivery.count({ where: { status: 'failed', createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
href: '/dashboard/admin/settings?tab=webhooks',
tone: 'rose',
})),
].filter(Boolean));
const order = ['privacy', 'consent', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks'];
return items.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
}
async function today(req, now = new Date()) {
const scope = await scopeFor(req);
const [events, money, pending] = await Promise.all([
todaysEvents(eventWhere(scope), now),
isSupervisor(req) ? moneyIn(scope, now) : Promise.resolve(null),
isSupervisor(req) ? pendingItems(req, scope, now) : Promise.resolve([]),
]);
return { events, money, pending };
}
// ─── Command palette search ──────────────────────────────────────────────────
/** "R450", "450", "r 450.50" → 450.5; anything else → null. Pure. */
function amountOf(q) {
const m = /^r?\s*(\d{1,7}(?:[.,]\d{1,2})?)$/i.exec(String(q || '').trim());
return m ? parseFloat(m[1].replace(',', '.')) : null;
}
/**
* Results for the command palette: events (everyone with dashboard access), and for supervisors
* payments by amount ("R450"); for admins also people and registrations. Each { type, id, title,
* subtitle, href }. Up to 5 of each kind.
*/
async function search(req, rawQuery, now = new Date()) {
const q = String(rawQuery || '').trim().slice(0, 80);
if (q.length < 2) return [];
const scope = await scopeFor(req);
const contains = { contains: q, mode: 'insensitive' };
const out = [];
const supervisor = isSupervisor(req);
const fmtDate = d => new Date(d).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' });
const events = await prisma.event.findMany({
where: { AND: [eventWhere(scope), { title: contains }] },
orderBy: { startDate: 'desc' }, take: 5,
select: { id: true, title: true, startDate: true, isActive: true, campus: { select: { name: true } } },
});
for (const e of events) {
out.push({
type: 'event', id: e.id, title: e.title,
subtitle: [fmtDate(e.startDate), e.campus?.name, e.isActive ? null : 'inactive'].filter(Boolean).join(' · '),
href: supervisor ? `/dashboard/supervisor/events?edit=${e.id}` : `/events/${e.id}`,
});
}
const amount = amountOf(q);
if (supervisor && amount !== null) {
const payments = await prisma.payment.findMany({
where: { amount: { in: [amount, -amount] }, createdAt: { gte: new Date(now.getTime() - 365 * DAY) }, ...(scope ? { event: eventWhere(scope) } : {}) },
orderBy: { createdAt: 'desc' }, take: 5,
select: { id: true, amount: true, method: true, createdAt: true, registrationId: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const p of payments) {
out.push({
type: 'payment', id: p.id, title: `R${p.amount.toFixed(2)} — ${p.user?.name || 'Unknown'}`,
subtitle: [p.method, p.event?.title, fmtDate(p.createdAt)].filter(Boolean).join(' · '),
href: isAdmin(req) && p.registrationId ? `/dashboard/admin/registrations?q=${p.registrationId}` : '/dashboard/supervisor/payments',
});
}
}
if (isAdmin(req)) {
const people = await prisma.user.findMany({
where: { isActive: true, OR: [{ name: contains }, { email: contains }, { phoneNumber: { contains: q.replace(/\s+/g, '') } }] },
orderBy: { name: 'asc' }, take: 5,
select: { id: true, name: true, email: true, role: true },
});
for (const u of people) {
out.push({ type: 'person', id: u.id, title: u.name, subtitle: [u.email, u.role !== 'user' ? u.role : null].filter(Boolean).join(' · '), href: `/dashboard/admin/users?q=${encodeURIComponent(u.email || u.name)}` });
}
// A registration by its reference (the start of its id), as on emails and invoices.
if (/^[0-9a-f-]{6,36}$/i.test(q)) {
const regs = await prisma.registration.findMany({
where: { id: { startsWith: q.toLowerCase() } }, take: 5,
select: { id: true, status: true, user: { select: { name: true } }, event: { select: { title: true } } },
});
for (const r of regs) {
out.push({ type: 'registration', id: r.id, title: `Registration ${r.id.slice(0, 8)} — ${r.user?.name || ''}`.trim(), subtitle: [r.event?.title, r.status].filter(Boolean).join(' · '), href: `/dashboard/admin/registrations?q=${r.id}` });
}
}
}
return out;
}
module.exports = { today, todaysEvents, moneyIn, pendingItems, search, amountOf, dayBounds };
+4
View File
@@ -465,6 +465,10 @@ 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 } });
// Their saved list filters (can hold search text, such as names).
await tx.savedFilter.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
+68 -5
View File
@@ -24,9 +24,9 @@ const DEFAULT_SECTIONS = [
title: 'Plain English Summary',
body: [
"We respect your privacy — here's what you need to know:",
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
'• We only collect info needed to run events: registrations, payments, tickets, check-in, volunteer rosters and, for children\'s ministry, kids check-in.',
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
"• We don't sell your data, and only share it with the services listed in section 9 — they process payments and deliver messages for us, and any other system we connect (such as church-management software) gets only the updates we chose.",
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
@@ -53,6 +53,8 @@ const DEFAULT_SECTIONS = [
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
'• Your home campus, if the organisation has more than one campus and you choose one.',
'• Serving (if you volunteer): the roles and shifts you sign up for or are given, requests to pass a shift on and who took it, when you checked in, and the hours you served.',
'• Event series you follow, and season passes you buy (a season pass registers you for each event on it, so each shows in your registrations).',
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
@@ -90,6 +92,8 @@ const DEFAULT_SECTIONS = [
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
'• Show you the events of your home campus first, if the organisation has more than one campus.',
'• Run volunteer rosters: remind you the day before you serve, pass your shift on to someone who accepts it when you can\'t make it, and check you in when you arrive.',
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
'• Send you an optional feedback form after an event, with one reminder.',
'• Manage user accounts and event attendance records, and give support when you ask for help.',
@@ -106,7 +110,7 @@ const DEFAULT_SECTIONS = [
'• Payment receipts.',
'• Your event tickets (as a PDF document).',
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
'• Session reminders, if the event sends them.',
'• Session reminders, if the event sends them, and volunteer reminders the day before you serve.',
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
@@ -133,6 +137,7 @@ const DEFAULT_SECTIONS = [
'We keep personal information only as long as we need it:',
'{retention}',
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
'• Copies of the updates sent to connected systems (webhooks, section 9) are kept for 30 days so problems can be checked, then deleted — and straight away when your account is deleted.',
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
].join('\n'),
@@ -161,10 +166,12 @@ const DEFAULT_SECTIONS = [
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
'{operators}',
'• Anyone we are required to share it with under South African law.',
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
'Within our organisation, people see only what they need:',
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
'• Staff running a volunteer roster see who is serving in each role, their contact details, check-ins and hours. If you ask for someone to take over your shift, your first name, the role and any note you add are shown to the event\x27s other volunteers and others who could take it over.',
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
@@ -205,6 +212,7 @@ const DEFAULT_SECTIONS = [
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
'• cross_events_dismissed_notices — notices from the software provider (such as planned maintenance) that you have closed.',
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
'Optional — only if you click "Allow" on the cookie notice:',
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
@@ -230,6 +238,7 @@ const DEFAULT_SECTIONS = [
title: '13. Changes to This Notice',
body: [
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
'You are asked to agree to this notice when you create an account, or when you activate an account that was made for you (for example when someone registered you, or at the door). If you have never agreed to it — such as an account from before we asked — we ask once, the next time you log in.',
'If a change is significant, we ask you to accept the new version the next time you log in.',
].join('\n'),
},
@@ -253,6 +262,49 @@ const DEFAULT_SECTIONS = [
},
];
/**
* Changes to the built-in text, newest last. A site that published its own version keeps its own
* text, so the editor lists the changes made since the revision it last reviewed
* (`privacy_notice_builtin_reviewed`) and offers the new text section by section.
*/
const BUILT_IN_UPDATES = [
{
revision: '1.48.1',
summary: [
'Volunteer rosters: what is kept about people who serve, reminders, and who sees the roster.',
'Following an event series, and season passes.',
'Webhooks: other systems the organisation connects, what they receive, and that copies are kept 30 days.',
'How people agree to the notice: at sign-up, at account activation, or once after login if they never have.',
'A browser-storage item for closed provider notices.',
],
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
},
];
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
function revisionLess(a, b) {
const pa = String(a || '0').split('.').map(Number);
const pb = String(b || '0').split('.').map(Number);
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
return false;
}
/**
* Built-in text changes this site hasn't reviewed yet. None while nothing is published (the site
* shows the built-in text, so it is always current).
*/
async function builtInUpdates(db = prisma) {
if (!(await latestNotice(db))) return [];
const row = await db.appSetting.findUnique({ where: { key: REVIEWED_KEY } });
return BUILT_IN_UPDATES.filter(u => !row?.value || revisionLess(row.value, u.revision));
}
/** The admin has gone through the built-in changes (taken what they want, or not). */
async function markBuiltInReviewed(db = prisma) {
await db.appSetting.upsert({ where: { key: REVIEWED_KEY }, update: { value: BUILT_IN_REVISION }, create: { key: REVIEWED_KEY, value: BUILT_IN_REVISION } });
}
function defaultSections() {
return DEFAULT_SECTIONS.map(s => ({ ...s }));
}
@@ -340,11 +392,17 @@ async function requiredVersion(db = prisma) {
return row ? row.version : null;
}
/** Whether someone must (re-)accept the notice before carrying on. */
/**
* Whether someone must accept the notice before carrying on, and why: 'first' — they never
* accepted any version (an account made for them by staff, at the door or by someone who
* registered them, or from before acceptances were recorded); 'updated' — a version marked as a
* significant change came out after the one they accepted. false when nothing is needed.
*/
async function noticePromptPending(user, db = prisma) {
if (user.noticeVersionAccepted == null) return 'first';
const required = await requiredVersion(db);
if (required == null) return false;
return (user.noticeVersionAccepted ?? -1) < required;
return user.noticeVersionAccepted < required ? 'updated' : false;
}
/**
@@ -447,6 +505,11 @@ async function publish({ sections, changeSummary, requiresAcceptance, publishedB
module.exports = {
PLACEHOLDERS,
BUILT_IN_UPDATES,
BUILT_IN_REVISION,
builtInUpdates,
markBuiltInReviewed,
revisionLess,
defaultSections,
cleanSections,
noticeContext,
+100
View File
@@ -0,0 +1,100 @@
/**
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
* with one click; one per page can be their default (applied when the page opens). Stored per
* person on the server, so they follow them to any device. Only their own are ever read or changed.
*/
const prisma = require('../config/db');
// The list pages that have saved filters, and who can open them.
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
const MAX_PER_PAGE = 30;
const MAX_PARAMS_BYTES = 4000;
function filterError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function cleanPage(page) {
const p = String(page || '');
if (!PAGES[p]) throw filterError('Unknown list page');
return p;
}
function cleanName(name) {
const n = String(name || '').trim().replace(/\s+/g, ' ');
if (!n) throw filterError('Give the filter a name');
return n.slice(0, 60);
}
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
function cleanParams(params) {
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
const out = {};
for (const [k, v] of Object.entries(params)) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
if (typeof v === 'string') out[k] = v.slice(0, 500);
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
else if (typeof v === 'boolean') out[k] = v;
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
}
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
return out;
}
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
async function list(userId, page) {
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
return rows.map(view);
}
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
async function save(userId, { page, name, params, isDefault = false }) {
const p = cleanPage(page);
const n = cleanName(name);
const data = cleanParams(params);
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
}
return prisma.$transaction(async tx => {
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
const row = existing
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
return view(row);
});
}
async function own(userId, id) {
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
return row;
}
/** Rename, or make (or stop being) the default. */
async function update(userId, id, { name, isDefault }) {
const row = await own(userId, id);
return prisma.$transaction(async tx => {
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
try {
return view(await tx.savedFilter.update({
where: { id: row.id },
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
}));
} catch (e) {
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
throw e;
}
});
}
async function remove(userId, id) {
const row = await own(userId, id);
await prisma.savedFilter.delete({ where: { id: row.id } });
}
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
+349
View File
@@ -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,
};
+95
View File
@@ -0,0 +1,95 @@
const mockDb = {
registration: { findMany: jest.fn(), findUnique: jest.fn() },
eventOption: { findMany: jest.fn() },
attendee: { findMany: jest.fn(), updateMany: jest.fn() },
registrationOption: { findMany: jest.fn(), update: jest.fn() },
registrationOptionTranche: { deleteMany: jest.fn(), create: jest.fn() },
user: { findUnique: jest.fn().mockResolvedValue({ role: "user" }) },
$transaction: jest.fn(fn => fn(mockDb)),
};
const mockLeft = jest.fn().mockResolvedValue(null);
jest.mock("../src/utils/stock", () => ({ remainingFor: (...a) => mockLeft(...a) }));
jest.mock("../src/utils/pricing", () => ({ resolveOptionPrice: jest.fn().mockResolvedValue({ price: 150, tierId: null }) }));
const mockStatus = jest.fn();
jest.mock("../src/payments/webhook", () => ({ updateRegistrationStatus: (...a) => mockStatus(...a) }));
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn() }));
const mockLicence = jest.fn().mockResolvedValue({ readOnly: false });
jest.mock('../src/hub/licence', () => ({ licenceStatus: (...a) => mockLicence(...a) }));
const mockCancel = jest.fn();
const mockUpdate = jest.fn();
jest.mock('../src/controllers/registrationController', () => ({
cancelRegistration: (req, res) => mockCancel(req, res),
updateRegistrationOptions: (req, res) => mockUpdate(req, res),
}));
const mockAssign = jest.fn();
jest.mock('../src/controllers/allocationController', () => ({ assign: (req, res) => mockAssign(req, res) }));
const mockEmail = jest.fn();
jest.mock('../src/controllers/eventController', () => ({ emailEventAttendees: (req, res) => mockEmail(req, res), whatsappEventAttendees: jest.fn() }));
const bulk = require('../src/utils/bulkRegistrations');
const { isBlocked } = require('../src/hub/readOnly');
const req = { user: { id: 'sup', role: 'supervisor' } };
const reg = (id, extra = {}) => ({ id, userId: `u-${id}`, eventId: 'ev1', status: 'pending', seasonPassPurchaseId: null, registrationOptions: [{ eventOptionId: 'adult', variantId: null, quantity: 1, sessionId: null }], ...extra });
beforeEach(() => jest.clearAllMocks());
test('the input is checked before anything runs', async () => {
await expect(bulk.runBulk(req, { action: 'explode', registrationIds: ['a'] })).rejects.toThrow('Unknown');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: [] })).rejects.toThrow('Tick at least one');
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: Array.from({ length: 501 }, (_, i) => `r${i}`) })).rejects.toThrow('At most 500');
});
test('cancelling runs the normal cancel for each; already-cancelled ones are skipped; a refusal is reported', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { status: 'cancelled' }), reg('c')]);
mockCancel.mockImplementation((r, res) => (r.params.id === 'c' ? res.status(403).json({ message: 'has payments' }) : res.json({ message: 'Registration cancelled' })));
const out = await bulk.runBulk(req, { action: 'cancel', registrationIds: ['a', 'b', 'c', 'gone'] });
expect(out).toMatchObject({ done: 1, skipped: 1, failed: 2 });
expect(out.results.find(r => r.id === 'c')).toMatchObject({ ok: false, message: 'has payments' });
expect(out.results.find(r => r.id === 'gone')).toMatchObject({ ok: false, message: 'Not found' });
expect(mockCancel).toHaveBeenCalledTimes(2);
expect(mockCancel.mock.calls[0][0].user).toBe(req.user);
});
test('moving keeps the same lines and tickets, repriced; checks places; not other events or season passes', async () => {
mockDb.eventOption.findMany.mockResolvedValue([
{ id: 'adult', eventId: 'ev1', name: 'Adult', event: { id: 'ev1', cashupStatus: 'open' } },
{ id: 'vip', eventId: 'ev1', name: 'VIP', event: { id: 'ev1', cashupStatus: 'open' } },
]);
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { eventId: 'ev2' }), reg('c', { seasonPassPurchaseId: 'p1' }), reg('d')]);
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'line1', quantity: 1 }]);
mockLeft.mockResolvedValueOnce(null).mockResolvedValueOnce(0);
mockStatus.mockResolvedValue({ totalDue: 150, totalPaid: 100 });
const out = await bulk.runBulk(req, { action: 'moveOption', registrationIds: ['a', 'b', 'c', 'd'], fromOptionId: 'adult', toOptionId: 'vip' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: true, message: 'Moved — R50.00 now owing' });
expect(mockDb.registrationOption.update).toHaveBeenCalledWith({ where: { id: 'line1' }, data: { eventOptionId: 'vip', variantId: null, priceSnapshot: 150, appliedTierId: null } });
expect(mockDb.attendee.updateMany).toHaveBeenCalledWith({ where: { registrationOptionId: 'line1' }, data: { eventOptionId: 'vip', variantId: null } });
expect(out.results.find(r => r.id === 'b').message).toBe('Not for this event');
expect(out.results.find(r => r.id === 'c').message).toMatch(/season pass/);
expect(out.results.find(r => r.id === 'd')).toMatchObject({ ok: false, message: 'Only 0 places left on VIP' });
});
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
mockLicence.mockResolvedValueOnce({ readOnly: true });
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
});
test('allocating places each named person; a registration without names says so', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b')]);
mockDb.attendee.findMany.mockImplementation(({ where }) => Promise.resolve(where.registrationId === 'a' ? [{ id: 'p1', name: 'Amy' }, { id: 'p2', name: 'Ben' }] : []));
mockAssign.mockImplementation((r, res) => (r.body.attendeeId === 'p2' ? res.status(400).json({ message: 'Cabin 3 is full' }) : res.json({})));
const out = await bulk.runBulk(req, { action: 'allocate', registrationIds: ['a', 'b'], setId: 's1', groupId: 'g1' });
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: false, message: 'Ben: Cabin 3 is full' });
expect(out.results.find(r => r.id === 'b').message).toMatch(/No named people/);
});
test('messages go per event as an event update to exactly the ticked registrations', async () => {
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b'), reg('c', { eventId: 'ev2' })]);
mockEmail.mockImplementation((r, res) => res.json({ sent: r.body.filter.registrationIds.length }));
const out = await bulk.runBulk(req, { action: 'message', registrationIds: ['a', 'b', 'c'], channel: 'email', subject: 'Bus times', text: 'The bus leaves at 7:30.' });
expect(out.done).toBe(3);
expect(mockEmail.mock.calls.map(c => [c[0].params.id, c[0].body.filter.registrationIds, c[0].body.kind])).toEqual([['ev1', ['a', 'b'], 'update'], ['ev2', ['c'], 'update']]);
await expect(bulk.runBulk(req, { action: 'message', registrationIds: ['a'], channel: 'email', text: 'x' })).rejects.toThrow('subject');
});
+86
View File
@@ -0,0 +1,86 @@
const count = jest.fn().mockResolvedValue(2);
const mockDb = {
registration: { count, findMany: jest.fn().mockResolvedValue([]) },
consentRequest: { findMany: jest.fn().mockResolvedValue([]) },
waitlistEntry: { count },
volunteerSwap: { count },
servingRole: { findMany: jest.fn().mockResolvedValue([{ needed: 3, _count: { slots: 1 } }, { needed: 2, _count: { slots: 2 } }]) },
event: { count, findMany: jest.fn().mockResolvedValue([]) },
providerTransaction: { count },
dataRequest: { count },
webhookDelivery: { count },
payment: { findMany: jest.fn().mockResolvedValue([]) },
user: { findMany: jest.fn().mockResolvedValue([]) },
};
jest.mock('../src/config/db', () => mockDb);
const mockScope = jest.fn();
jest.mock('../src/utils/campusScope', () => ({
scopeFor: (...a) => mockScope(...a),
eventWhere: scope => (scope ? { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] } : {}),
}));
const dashboard = require('../src/utils/dashboard');
beforeEach(() => jest.clearAllMocks());
test('an amount typed in the palette', () => {
expect(dashboard.amountOf('R450')).toBe(450);
expect(dashboard.amountOf('r 450,50')).toBe(450.5);
expect(dashboard.amountOf('450.5')).toBe(450.5);
expect(dashboard.amountOf('Camp 2026')).toBeNull();
expect(dashboard.amountOf('')).toBeNull();
});
describe('what is waiting', () => {
test('admins see everything they can act on', async () => {
const items = await dashboard.pendingItems({ user: { role: 'admin' } }, null);
expect(items.map(i => i.key)).toEqual(['privacy', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks']);
// Only roles still short of people count.
expect(items.find(i => i.key === 'roster').count).toBe(1);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/admin/registrations?status=pending');
});
test('a campus-limited supervisor: no cashups, provider payments, privacy or webhooks; only their events', async () => {
const scope = { campusIds: ['north'] };
const items = await dashboard.pendingItems({ user: { role: 'supervisor' } }, scope);
expect(items.map(i => i.key)).toEqual(['unpaid', 'cover', 'roster', 'waitlist']);
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/supervisor/reports?report=regStatus');
const where = mockDb.registration.count.mock.calls[0][0].where;
expect(where.event.AND[0]).toEqual({ OR: [{ campusId: { in: ['north'] } }, { campusId: null }] });
});
test('items with nothing waiting are left out', async () => {
count.mockResolvedValue(0);
mockDb.servingRole.findMany.mockResolvedValueOnce([]);
expect(await dashboard.pendingItems({ user: { role: 'admin' } }, null)).toEqual([]);
count.mockResolvedValue(2);
});
});
describe('palette search', () => {
test('too short a query finds nothing', async () => {
expect(await dashboard.search({ user: { role: 'admin' } }, 'a')).toEqual([]);
});
test('staff get events only, linked to the public page', async () => {
mockScope.mockResolvedValue(null);
mockDb.event.findMany.mockResolvedValueOnce([{ id: 'e1', title: 'Camp 2026', startDate: new Date('2026-03-01'), isActive: true, campus: null }]);
const out = await dashboard.search({ user: { role: 'staff' } }, 'camp');
expect(out).toEqual([expect.objectContaining({ type: 'event', href: '/events/e1' })]);
expect(mockDb.user.findMany).not.toHaveBeenCalled();
expect(mockDb.payment.findMany).not.toHaveBeenCalled();
});
test('supervisors find payments by amount; admins also people', async () => {
mockScope.mockResolvedValue(null);
mockDb.payment.findMany.mockResolvedValueOnce([{ id: 'p1', amount: 450, method: 'card', createdAt: new Date(), registrationId: 'r1', user: { name: 'Thandi' }, event: { title: 'Camp' } }]);
const sup = await dashboard.search({ user: { role: 'supervisor' } }, 'R450');
expect(sup.find(r => r.type === 'payment')).toMatchObject({ title: 'R450.00 — Thandi', href: '/dashboard/supervisor/payments' });
expect(mockDb.payment.findMany.mock.calls[0][0].where.amount).toEqual({ in: [450, -450] });
expect(mockDb.user.findMany).not.toHaveBeenCalled();
mockDb.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Thandi Test', email: 't@test.local', role: 'user' }]);
const adm = await dashboard.search({ user: { role: 'admin' } }, 'thandi');
expect(adm.find(r => r.type === 'person')).toMatchObject({ href: '/dashboard/admin/users?q=t%40test.local' });
});
});
+39 -5
View File
@@ -60,18 +60,23 @@ describe('cleanSections', () => {
});
describe('noticePromptPending', () => {
test('nobody is asked while no version needs re-acceptance', async () => {
test('someone who never accepted any version is asked once (first)', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue(null);
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe('first');
});
test('nobody else is asked while no version needs re-acceptance', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue(null);
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe(false);
});
test('asked when they accepted an older version than the newest one that needs it', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe('updated');
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe('updated');
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
expect(prisma.privacyNotice.findFirst.mock.calls.at(-1)[0].where).toEqual({ requiresAcceptance: true });
});
});
@@ -107,3 +112,32 @@ describe('reportRowCount', () => {
expect(reportRowCount(null)).toBe(0);
});
});
describe('changes to the built-in text', () => {
test('a site that never published shows the built-in text, so there is nothing to review', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue(null);
expect(await notice.builtInUpdates()).toEqual([]);
});
test('a site with its own version is shown the changes it has not reviewed', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 2 });
prisma.appSetting.findUnique.mockResolvedValue(null);
expect((await notice.builtInUpdates()).map(u => u.revision)).toContain('1.48.1');
prisma.appSetting.findUnique.mockResolvedValue({ value: notice.BUILT_IN_REVISION });
expect(await notice.builtInUpdates()).toEqual([]);
});
test('every changed section exists in the built-in text', () => {
const ids = notice.defaultSections().map(s => s.id);
for (const u of notice.BUILT_IN_UPDATES) for (const id of u.sections) expect(ids).toContain(id);
expect(notice.revisionLess('1.47.9', '1.48.1')).toBe(true);
expect(notice.revisionLess('1.48.10', '1.48.9')).toBe(false);
});
test('the built-in text covers the features since 1.46', () => {
const all = notice.defaultSections().map(s => s.body).join('\n');
for (const words of ['volunteer', 'series you follow', 'season pass', 'webhook', 'cross_events_dismissed_notices', 'activate an account']) {
expect([words, all.toLowerCase().includes(words.toLowerCase())]).toEqual([words, true]);
}
});
});
+41
View File
@@ -0,0 +1,41 @@
const mockDb = {
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
$transaction: jest.fn(fn => fn(mockDb)),
};
jest.mock('../src/config/db', () => mockDb);
const filters = require('../src/utils/savedFilters');
beforeEach(() => jest.clearAllMocks());
test('only simple filter values are kept', () => {
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
});
test('pages and names are checked', async () => {
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
expect(() => filters.cleanName(' ')).toThrow('name');
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
});
test('saving a default clears the old default; saving the same name replaces it', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
});
test('at most 30 per page', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue(null);
mockDb.savedFilter.count.mockResolvedValue(30);
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
});
test('someone else\'s saved filter is "not found"', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
});
+115
View File
@@ -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' });
});
});
+86 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.47.4** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.49.2** (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,12 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
@@ -52,7 +58,7 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.48.0 — outbound webhooks** (§8).
1. **1.50.0 — Languages (Afrikaans first)** (§10), then 1.51.0 accessibility (§11).
## Working on this repo
@@ -166,7 +172,15 @@ a test server (both done 2026-10-28).
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
changes the live notice only for installs that haven't published a version yet.**
changes the live notice only for installs that haven't published a version yet** — so every
change to `DEFAULT_SECTIONS` must also add an entry to `BUILT_IN_UPDATES` (the release, a summary,
the section ids changed). Sites with their own version then see it in the editor until they press
"Done — hide this" (AppSetting `privacy_notice_builtin_reviewed`). A test checks the ids exist.
- Accepting the notice: sign-up (`source: 'signup'`), account activation (`'activation'`, the form's
terms box) and the after-login prompt (`'prompt'`). `noticePromptPending` is `'first'` for anyone
who never accepted (`noticeVersionAccepted` null) and `'updated'` when a significant version came
out later. The e2e seed sets `noticeVersionAccepted: 0` so the prompt doesn't cover every test.
- The cookie button on the privacy page shows above any section whose id contains "cookie".
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
**Any new bulk export or list of personal data should call it** and get a label in
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
@@ -332,6 +346,75 @@ a test server (both done 2026-10-28).
(staff cancel and refund each event's registration), one combined payment email (each event's
payment sends its own).
## Where outbound webhooks live (1.48.0)
- **Noticing:** `backend/src/config/db.js` wraps the Prisma client with `$extends` query hooks
(registration create/update to paid or cancelled, payment create, ticketUsage create, user create,
event create/update to public). **New code gets webhooks for free** as long as it writes through
`prisma` (`updateMany` isn't hooked). Hooks only call `notice`; never put slow work there.
- `backend/src/utils/webhooks.js` — event types, the in-memory queue (`notice` → `flush` after 1.5 s,
which re-reads the record and skips it if it changed back), payload builders (kids/volunteer tickets
left out), `send` (claim, SSRF check, sign, 10 s timeout, retry schedule), `runJob` (`webhooks` job:
due retries, stuck sends, 30-day prune), `resend`, `sendTest`. Queued events are lost if the
process stops in that 1.5 s (no outbox table yet).
- `controllers/outboundWebhookController.js` + `routes/outboundWebhookRoutes.js`
(`/api/outbound-webhooks`, admin). Operator register entry per endpoint (`operatorId`).
- **Adding an event type:** add it to `EVENT_TYPES`, a case in `buildPayload`, a hook in `db.js` if
needed, and a line in `dataShared` (operator register text).
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
## Demo data (1.48.2)
- `backend/scripts/set-demo.js` builds everything in memory and writes it with `createMany` in one
transaction (so no webhooks fire). `PROFILES` says which of the six upcoming events, which past
events (by title) and which extras (`series`, `courses`, `conference`, `kids`, `volunteers`) each
profile gets, and whether campuses are on. **A new feature that's worth demoing** gets a builder
and an extra; add its tables to `INSERT_ORDER` (parents first).
- Keep jobs quiet: set anything a job would act on as already done (`seriesAnnouncedAt`,
`remindedAt`, certificates issued). Demo people use @example.com and no phone numbers.
- `clearData` truncates every table except settings and users, but deletes (not truncates) tables
that `User` points at — TRUNCATE CASCADE would empty `User` with them.
- Tried by seeding all four into a throwaway database and calling the API (events, series page and
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
scoping).
## Where the dashboard and palette live (1.49.0)
- `backend/src/utils/dashboard.js` — `todaysEvents`, `moneyIn`, `pendingItems` (each item counted
separately, failures logged and skipped; role-only items for admins), `search` (+ `amountOf`).
Everything goes through `eventWhere(scopeFor(req))`. Routes: `routes/dashboardRoutes.js`.
**A new "something needs doing" count** goes in `pendingItems` (with its link and the `order`).
- Frontend: `components/dashboard/TodayPanel.tsx`, `components/shared/CommandPalette.tsx` (mounted in
`app/dashboard/layout.tsx`; `CommandPaletteButton` on the dashboards), `lib/commandPages.ts` —
**a new page or setting tab should be added there** with its roles.
- Deep links read `window.location.search` once on load (no Suspense needed).
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
## Where bulk actions live (1.49.1)
- `backend/src/utils/bulkRegistrations.js` — `runBulk` checks the input (≤ 500, licence read-only for
payments and moves) and runs one action per registration, collecting a result for each. Most
actions **call the existing Express handlers in-process** (`call(handler, req)`, with the original
request's user, headers and socket so audits keep the IP): `createPayment`, `cancelRegistration`,
`allocationController.assign`, `emailEventAttendees` / `whatsappEventAttendees` (new
`filter.registrationIds`). **Change a rule in those handlers and bulk follows.**
- Moving tickets is its own code (`moveOption`): the edit handler refuses to remove issued tickets,
so the lines are re-pointed in place (tickets and QR codes kept), repriced with `resolveOptionPrice`,
attendees moved, then `updateRegistrationStatus`.
- Frontend: `components/admin/RegistrationBulkBar.tsx` on `app/dashboard/admin/registrations`. Export
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
`bulk.spec.ts`.
## Saved filters (1.49.2)
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
- e2e `saved-filters.spec.ts` (deletes what it saves).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+11 -4
View File
@@ -35,7 +35,9 @@ up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticke
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) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. **Next: 1.48.0 Outbound webhooks.**
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. **Next: 1.50.0
Languages (Afrikaans first).**
**Repositories:**
@@ -500,7 +502,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`,
@@ -514,6 +518,9 @@ Let other systems react to what happens in Cross Events.
## 9. Admin dashboard, command palette, bulk actions
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
outstanding, waitlist offers, data requests), alerts.
@@ -964,8 +971,8 @@ 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.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 |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 – 1.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 |
+86
View File
@@ -0,0 +1,86 @@
import { test, expect, loginAs, api } from "./fixtures";
// Bulk actions on the registrations list (1.49.1): tick several, mark them paid, move them to
// another ticket, cancel — each through the same rules as one at a time.
type Ev = { id: string; eventOptions?: { id: string; name: string; isMainTicket?: boolean }[] };
type Reg = { id: string; status: string; registrationOptions?: { eventOptionId: string; quantity: number }[]; payments?: { amount: number; method: string }[] };
test.describe.serial("bulk actions on registrations", () => {
let event: Ev;
let main: string;
let vip: string;
const regs: string[] = [];
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
const start = new Date(Date.now() + 20 * 864e5);
event = await api<Ev>(page, "admin", "POST", "/api/events", {
title: "E2E Bulk Night", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3 * 3600e3).toISOString(),
price: 100, location: "Church hall",
});
const full = await api<Ev>(page, "admin", "GET", `/api/events/${event.id}`);
main = full.eventOptions!.find(o => o.isMainTicket)!.id;
vip = (await api<{ id: string }>(page, "admin", "POST", `/api/events/${event.id}/options`, { name: "VIP", price: 150, isMainTicket: true })).id;
for (const role of ["staff", "supervisor", "user"] as const) {
regs.push((await api<{ id: string }>(page, role, "POST", "/api/registrations", { eventId: event.id, options: [{ eventOptionId: main, quantity: 1 }] })).id);
}
await page.close();
});
const openList = async (page: import("@playwright/test").Page) => {
await loginAs(page, "admin");
await page.goto(`/dashboard/admin/registrations?event=${event.id}`);
await expect(page.getByText("3 of", { exact: false })).toBeVisible();
};
test("tick all and mark them paid in cash", async ({ page }) => {
await openList(page);
await page.getByLabel("Select all shown").check();
const bar = page.getByTestId("bulk-bar");
await expect(bar).toContainText("3 selected");
await bar.getByLabel("Bulk action").selectOption("markPaid");
await bar.getByLabel("Paid by").selectOption("cash");
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Mark paid/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("3 done");
for (const id of regs) {
const r = await api<Reg>(page, "admin", "GET", `/api/registrations/${id}`);
expect(r.status).toBe("paid");
expect(r.payments?.map(p => [p.amount, p.method])).toEqual([[100, "cash"]]);
}
});
test("move two to the VIP ticket; cancel the third", async ({ page }) => {
await openList(page);
const rows = page.locator("li").filter({ has: page.getByRole("checkbox", { name: /^Select / }) });
await rows.nth(0).getByRole("checkbox").check();
await rows.nth(1).getByRole("checkbox").check();
const bar = page.getByTestId("bulk-bar");
await bar.getByLabel("Bulk action").selectOption("moveOption");
await bar.getByLabel("Move from").selectOption(main);
await bar.getByLabel("Move to").selectOption(vip);
page.once("dialog", d => d.accept());
await bar.getByRole("button", { name: /Move to another ticket/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("2 done");
const moved = await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)));
const onVip = moved.filter(r => r.registrationOptions?.some(o => o.eventOptionId === vip));
expect(onVip).toHaveLength(2);
// R150 now, R100 paid: they owe the difference.
expect(onVip.every(r => r.status === "partial_paid")).toBe(true);
await bar.getByRole("button", { name: "Clear selection" }).click();
const left = moved.find(r => !r.registrationOptions?.some(o => o.eventOptionId === vip))!;
await page.getByRole("checkbox", { name: /^Select / }).nth(2).check();
const selectedId = (await api<Reg>(page, "admin", "GET", `/api/registrations/${left.id}`)).id;
expect(selectedId).toBe(left.id);
const bar2 = page.getByTestId("bulk-bar");
await bar2.getByLabel("Bulk action").selectOption("cancel");
page.once("dialog", d => d.accept());
await bar2.getByRole("button", { name: /Cancel — 1/ }).click();
await expect(page.getByTestId("bulk-result")).toContainText("1 done");
const cancelled = (await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)))).filter(r => r.status === "cancelled");
expect(cancelled).toHaveLength(1);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect, loginAs } from "./fixtures";
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
test.describe("dashboard and command palette", () => {
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin");
const today = page.getByTestId("today-panel");
await expect(today).toBeVisible();
// Seeded on today: the hidden kids church and serving Sunday.
await expect(today.getByTestId("today-events")).toContainText("E2E Serving Sunday");
await expect(today.getByText("Waiting for you")).toBeVisible();
});
test("Ctrl+K finds an event and opens it", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByTestId("today-panel")).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await expect(dialog).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("Paid Conference");
await expect(dialog.getByRole("option", { name: /E2E Paid Conference/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/dashboard\/supervisor\/events/);
await expect(page.getByRole("heading", { name: "Edit Event" })).toBeVisible();
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Paid Conference");
});
test("the palette's actions and pages follow the role", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await page.getByRole("button", { name: /Search or jump to/ }).click();
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("new event");
await dialog.getByRole("option", { name: "New event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
// Staff don't get admin pages.
await loginAs(page, "staff");
await page.goto("/dashboard/staff");
await page.keyboard.press("Control+k");
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
await expect(staffDialog.getByRole("option", { name: "Users" })).toHaveCount(0);
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
});
});
+1
View File
@@ -11,6 +11,7 @@ export type Seed = {
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
noticeFirstUser: { id: string; token: string };
retention: { toWarn: string; toAnonymise: string };
};
+47 -1
View File
@@ -11,7 +11,7 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
// A version that needs accepting is published below; accept it for every seeded account
// afterwards so later test files don't meet the prompt.
test.afterAll(async ({ request }) => {
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token, seed().noticeFirstUser.token];
for (const token of tokens) {
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
}
@@ -55,6 +55,52 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
});
test("after publishing its own version, the editor points out new built-in text", async ({ page }) => {
// The published version came from the built-in text; put an older wording back in one changed
// section so it shows as having new text.
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
const sections = draft.sections.map(s => (s.id === "changes" ? { ...s, body: "We may update this notice from time to time." } : s));
await api(page, "admin", "PUT", "/api/privacy/notice/draft", { sections });
await loginAs(page, "admin");
await page.goto("/dashboard/admin/privacy?tab=notice");
const banner = page.getByTestId("builtin-updates");
await expect(banner).toContainText("The built-in privacy notice has been updated");
await expect(banner).toContainText("How people agree to the notice");
const marked = page.getByTestId("section-has-new-text");
await expect(marked).toHaveCount(1);
await marked.getByText("New built-in text for this section — show it").click();
await marked.getByRole("button", { name: "Use the new text" }).click();
await expect(page.getByTestId("section-has-new-text")).toHaveCount(0);
await page.getByRole("button", { name: "Save draft" }).click();
await expect(page.getByText("Draft saved.")).toBeVisible();
await banner.getByRole("button", { name: "Done — hide this" }).click();
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
await page.reload();
await expect(page.getByLabel("1. Introduction text")).toBeVisible();
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
await api(page, "admin", "DELETE", "/api/privacy/notice/draft");
});
test("someone who never accepted the notice is asked once, after login", async ({ page }) => {
await page.addInitScript(token => localStorage.setItem("hope_events_token", token), seed().noticeFirstUser.token);
await page.goto("/dashboard/user");
const dialog = page.getByRole("dialog", { name: "Our privacy notice" });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("It explains what we keep about you");
await dialog.getByRole("button", { name: "I accept" }).click();
await expect(dialog).toBeHidden();
await page.reload();
await expect(page.getByText("My Events").first()).toBeVisible();
await expect(page.getByRole("dialog", { name: "Our privacy notice" })).toHaveCount(0);
});
test("the cookie button stays on a published notice", async ({ page }) => {
await page.goto("/legal/privacy");
await expect(page.getByRole("button", { name: "Change my cookie choice" })).toBeVisible();
});
test("a significant change is accepted once, after login", async ({ page }) => {
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
// Re-acceptance needs a summary of what changed.
+33
View File
@@ -0,0 +1,33 @@
import { test, expect, loginAs } from "./fixtures";
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
// when the page opens), and delete them again.
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/registrations");
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
await status.selectOption("cancelled");
const saved = page.getByTestId("saved-filters");
await saved.getByRole("button", { name: /Saved filters/ }).click();
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
await saved.getByLabel("Use when this page opens").check();
await saved.getByRole("button", { name: "Save", exact: true }).click();
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
// The default is applied when the page opens…
await page.reload();
await expect(status).toHaveValue("cancelled");
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
// …but not over a link that says what to show.
await page.goto("/dashboard/admin/registrations?status=paid");
await expect(status).toHaveValue("paid");
await saved.getByRole("button", { name: /Saved filters/ }).click();
page.once("dialog", d => d.accept());
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
await expect(saved.getByText("No saved filters yet")).toBeVisible();
await page.goto("/dashboard/admin/registrations");
await expect(status).toHaveValue("");
});
+9 -4
View File
@@ -48,10 +48,10 @@ async function seed(url) {
});
const password = await hashPassword('E2e-password-123');
// Opted in to news and already asked (newsAskedAt), so the one-time news question
// doesn't cover the dashboards in every test.
// Opted in to news and already asked (newsAskedAt), and accepted the built-in privacy notice
// (version 0), so neither one-time question covers the dashboards in every test.
const mk = (role, name, email, phoneNumber) => prisma.user.create({
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() },
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date(), noticeVersionAccepted: 0 },
});
const users = {
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
@@ -195,7 +195,12 @@ async function seed(url) {
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, noticeVersionAccepted: 0 } });
return { id: u.id, token: generateToken(u.id, u.role, 0) };
})(),
// An account made for someone (never accepted the privacy notice): asked once after login.
noticeFirstUser: await (async () => {
const u = await prisma.user.create({ data: { name: 'Pieter Placed', email: 'placed@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, newsAskedAt: new Date() } });
return { id: u.id, token: generateToken(u.id, u.role, 0) };
})(),
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
+70
View File
@@ -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");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.47.4",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.47.4",
"version": "1.49.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.47.4",
"version": "1.49.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+14 -2
View File
@@ -1,5 +1,6 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
@@ -16,8 +17,11 @@ function ActivateAccountContent() {
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// An account made for them (by staff, at the door, or by whoever registered them): they agree
// to the terms and privacy notice here, as at sign-up.
const [accepted, setAccepted] = useState(false);
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]);
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token && accepted, [password, confirm, token, accepted]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -25,7 +29,7 @@ function ActivateAccountContent() {
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
@@ -64,6 +68,14 @@ function ActivateAccountContent() {
{password && confirm && password !== confirm && (
<p className="text-xs text-red-600">Passwords do not match.</p>
)}
<div className="flex items-start gap-2">
<input id="terms" type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600" required />
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
I confirm that I&apos;ve read and agree to the{" "}
<Link href="/legal/terms" target="_blank" className="text-brand-600 hover:underline">Terms of Use</Link> and{" "}
<Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">Privacy Policy</Link>.
</label>
</div>
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full py-2.5 font-medium">
{loading ? "Activating…" : "Activate my account"}
</button>
@@ -121,6 +121,7 @@ const ACTION_LABELS: Record<string, string> = {
series_updated: "Event series changed",
series_deleted: "Event series deleted",
season_pass_changed: "Season pass created, changed or deleted",
registrations_bulk: "Bulk action on registrations",
};
interface AuditLogEntry {
+13 -3
View File
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -124,11 +126,19 @@ export default function AdminDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
<CommandPaletteButton />
</div>
</div>
<TodayPanel supervisor />
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to use these tools.
@@ -9,6 +9,8 @@ import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, FormResponse, Registration } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
import { SavedFilters } from "@/components/shared/SavedFilters";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -54,6 +56,20 @@ export default function AdminRegistrationsPage() {
const [includePastEvents, setIncludePastEvents] = useState(false);
const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false);
// Deep links (the dashboard's "Waiting for you" list and the Ctrl+K palette): ?q=, ?status=, ?event=.
// A search by reference may be for a past event's registration, so past events are included then.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const q = params.get("q");
if (q) { setQuery(q); setIncludePastEvents(true); }
if (params.get("status")) setStatusFilter(params.get("status") || "");
if (params.get("event")) setEventFilter(params.get("event") || "");
}, []);
// Ticked for a bulk action (1.49.1).
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggleSelected = (id: string) => setSelected(prev => { const s = new Set(prev); if (s.has(id)) s.delete(id); else s.add(id); return s; });
// Expanded rows + form responses cache
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
@@ -163,6 +179,10 @@ export default function AdminRegistrationsPage() {
});
}, [registrations, query, eventFilter, statusFilter, eventIds]);
// Only ticked registrations still shown by the filters are acted on.
const selectedRegs = useMemo(() => filtered.filter(r => selected.has(r.id)), [filtered, selected]);
const allShownSelected = filtered.length > 0 && filtered.every(r => selected.has(r.id));
const statusColor = (s: string) => {
if (s === "paid") return "text-green-700 bg-green-50";
if (s === "confirmed") return "text-blue-700 bg-blue-50";
@@ -273,11 +293,30 @@ export default function AdminRegistrationsPage() {
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
{loadingRegs ? "Loading…" : "Refresh"}
</button>
<SavedFilters
page="registrations"
value={{ q: query, event: eventFilter, status: statusFilter, past: includePastEvents, inactive: includeInactiveEvents }}
onApply={p => {
setQuery(String(p.q || "")); setEventFilter(String(p.event || "")); setStatusFilter(String(p.status || ""));
setIncludePastEvents(p.past === true); setIncludeInactiveEvents(p.inactive === true);
}}
skipDefault={typeof window !== "undefined" && /[?&](q|status|event)=/.test(window.location.search)}
/>
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
</div>
{token && selectedRegs.length > 0 && (
<RegistrationBulkBar selected={selectedRegs} events={events} token={token} onClear={() => setSelected(new Set())} onDone={loadRegistrations} />
)}
<div className="border rounded-xl bg-white shadow-sm">
{filtered.length > 0 && (
<label className="flex items-center gap-2 px-3 py-2 border-b text-xs text-gray-600 cursor-pointer">
<input type="checkbox" aria-label="Select all shown" checked={allShownSelected} onChange={e => setSelected(e.target.checked ? new Set(filtered.map(r => r.id)) : new Set())} />
Select all {filtered.length} shown
</label>
)}
<ul className="divide-y text-sm">
{filtered.map((r) => {
const totalDue = totalDueFor(r);
@@ -291,6 +330,10 @@ export default function AdminRegistrationsPage() {
<li key={r.id} className="hover:bg-gray-50">
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
<div className="flex items-start justify-between gap-2">
<input
type="checkbox" className="mt-1 shrink-0" aria-label={`Select ${r.user?.name || r.userId}`}
checked={selected.has(r.id)} onClick={e => e.stopPropagation()} onChange={() => toggleSelected(r.id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{r.user?.name || r.userId}</span>
@@ -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"} />}
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
interface UserItem {
id: string;
@@ -58,6 +59,12 @@ export default function AdminUsersPage() {
const [roleFilter, setRoleFilter] = useState<Role | "">("");
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
// ?q= (the Ctrl+K palette links here with a person's email).
useEffect(() => {
const q = new URLSearchParams(window.location.search).get("q");
if (q) { setQuery(q); setDebouncedQuery(q); }
}, []);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 350);
@@ -353,6 +360,17 @@ export default function AdminUsersPage() {
<option value="false">Inactive only</option>
</select>
</div>
<SavedFilters
page="users" className="self-end"
value={{ q: query, role: roleFilter, active: activeFilter }}
onApply={p => {
const q = String(p.q || "");
setQuery(q); setDebouncedQuery(q);
setRoleFilter(String(p.role || "") as Role | "");
setActiveFilter(String(p.active || "") as "" | "true" | "false");
}}
skipDefault={typeof window !== "undefined" && /[?&]q=/.test(window.location.search)}
/>
<div>
<label className="block text-xs text-gray-600 mb-1">Per page</label>
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
+3
View File
@@ -9,6 +9,7 @@ import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
import { ProviderNotices } from "@/components/shared/ProviderNotices";
import { CommandPalette } from "@/components/shared/CommandPalette";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -92,6 +93,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<CommandPalette />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
<Sidebar />
@@ -110,6 +112,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<CommandPalette />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
+11 -2
View File
@@ -11,6 +11,8 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -76,11 +78,18 @@ export default function StaffDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
</div>
<CommandPaletteButton />
</div>
<CampusScopeNote className="mt-2" />
</div>
<TodayPanel supervisor={false} />
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to use staff tools.
@@ -10,6 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { Calendar, Copy } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
@@ -2113,6 +2114,19 @@ export default function ManageEventsPage() {
};
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
// Deep links (the Ctrl+K palette): ?edit=<eventId> opens that event, ?new=1 a new one.
const deepLinked = useRef(false);
useEffect(() => {
if (deepLinked.current || !token) return;
deepLinked.current = true;
const params = new URLSearchParams(window.location.search);
const edit = params.get("edit");
if (edit) openEdit({ id: edit } as Event);
else if (params.get("new") === "1") openCreate();
if (edit || params.get("new")) window.history.replaceState(null, "", window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
@@ -2146,6 +2160,14 @@ export default function ManageEventsPage() {
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
</label>
<SavedFilters
page="events"
value={{ q: search, campus, past: includePast, inactive: showInactive, closed: showClosed }}
onApply={p => {
setSearch(String(p.q || "")); setCampus(String(p.campus || ""));
setIncludePast(p.past === true); setShowInactive(p.inactive === true); setShowClosed(p.closed === true);
}}
/>
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
+13 -3
View File
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -120,12 +122,20 @@ export default function SupervisorDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
<CommandPaletteButton />
</div>
<CampusScopeNote className="mt-2" />
</div>
<TodayPanel supervisor />
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use these tools.
@@ -24,6 +24,7 @@ type Msg = { type: "ok" | "err"; text: string } | null;
const SOURCE_LABEL: Record<string, string> = {
signup: "when you signed up",
activation: "when you activated your account",
privacy_centre: "in your privacy settings",
prompt: "when we asked after logging in",
unsubscribe_link: "with an unsubscribe link",
@@ -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);
@@ -0,0 +1,198 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { CheckSquare, X, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { downloadReportExcel } from "@/lib/export";
import type { Event, Registration } from "@/types";
type Action = "" | "message" | "markPaid" | "moveOption" | "allocate" | "cancel" | "export";
type Result = { id: string; ok: boolean; skipped?: boolean; message: string };
type BulkResponse = { action: string; done: number; skipped: number; failed: number; results: Result[] };
type AllocationSet = { id: string; name: string; groups: { id: string; name: string }[] };
const ACTIONS: [Action, string][] = [
["message", "Send a message"],
["markPaid", "Mark paid (record the balance)"],
["moveOption", "Move to another ticket"],
["allocate", "Put in a room or group"],
["export", "Export to Excel"],
["cancel", "Cancel"],
];
const owing = (r: Registration) => {
const due = Number(r.totalDueComputed ?? 0);
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return Math.max(0, due - paid);
};
/**
* The bar above the registrations list while some are ticked (1.49.1): one action for all of
* them, run through the same rules as doing it one at a time, with a result per registration.
*/
export function RegistrationBulkBar({ selected, events, token, onClear, onDone }: {
selected: Registration[]; events: Event[]; token: string; onClear: () => void; onDone: () => void;
}) {
const [action, setAction] = useState<Action>("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<BulkResponse | null>(null);
// Per-action inputs.
const [channel, setChannel] = useState<"email" | "whatsapp">("email");
const [subject, setSubject] = useState("");
const [text, setText] = useState("");
const [method, setMethod] = useState("cash");
const [fromOptionId, setFromOptionId] = useState("");
const [toOptionId, setToOptionId] = useState("");
const [sets, setSets] = useState<AllocationSet[]>([]);
const [groupKey, setGroupKey] = useState("");
const eventIds = useMemo(() => [...new Set(selected.map(r => r.eventId))], [selected]);
const oneEvent = eventIds.length === 1 ? events.find(e => e.id === eventIds[0]) || null : null;
const options = useMemo(() => (oneEvent?.eventOptions || []).filter(o => o.id !== oneEvent?.kidsOptionId && o.id !== oneEvent?.volunteerOptionId), [oneEvent]);
const totalOwing = useMemo(() => selected.filter(r => r.status !== "cancelled").reduce((s, r) => s + owing(r), 0), [selected]);
useEffect(() => {
if (action !== "allocate" || !oneEvent) { setSets([]); return; }
apiFetch<{ sets?: AllocationSet[] }>(`/api/allocations/event/${oneEvent.id}`, { authToken: token })
.then(b => setSets((b.sets || []).filter(s => s.groups.length)))
.catch(() => setSets([]));
}, [action, oneEvent, token]);
const needsOneEvent = (action === "moveOption" || action === "allocate") && !oneEvent;
const exportSelected = async () => {
await downloadReportExcel(API_BASE, token, {
title: "Registrations", subtitle: `${selected.length} selected · ${new Date().toLocaleString()}`, kind: "table", orientation: "landscape",
table: {
columns: ["Reference", "Name", "Email", "Phone", "Event", "Status", "Total", "Paid", "Owing", "Tickets"],
rows: selected.map(r => {
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return [
String(r.id).slice(0, 8), r.user?.name || "", r.user?.email || "", r.user?.phoneNumber || "", r.event?.title || "", r.status,
`R ${Number(r.totalDueComputed ?? 0).toFixed(2)}`, `R ${paid.toFixed(2)}`, `R ${owing(r).toFixed(2)}`,
(r.registrationOptions || []).map(o => `${o.quantity} × ${o.eventOption?.name || ""}${o.variant?.name ? ` (${o.variant.name})` : ""}`).join(", "),
];
}),
},
});
};
const run = async () => {
setError(null); setResult(null);
if (action === "export") {
setBusy(true);
try { await exportSelected(); } catch (e) { setError(errorMessage(e) || "Couldn't export"); } finally { setBusy(false); }
return;
}
const confirmText: Record<string, string> = {
cancel: `Cancel ${selected.length} registration(s)? Cancelling doesn't refund — record refunds on the Payments page. Coupon uses are released and the waitlist is offered the places.`,
markPaid: `Record ${method.toUpperCase()} payments for what ${selected.length} registration(s) still owe (about R ${totalOwing.toFixed(2)})? Paid registrations get their tickets by email.`,
moveOption: `Move ${selected.length} registration(s) to the other ticket? Tickets already issued move with them (same QR code). The price follows the new ticket, so anything now owing or overpaid shows on each registration.`,
message: `Send this ${channel === "email" ? "email" : "WhatsApp"} to the people on ${selected.length} registration(s)?`,
allocate: `Put the named people on ${selected.length} registration(s) in this group?`,
};
if (!confirm(confirmText[action])) return;
const [setId, groupId] = groupKey.split("::");
const params = action === "message" ? { channel, subject, text }
: action === "markPaid" ? { method }
: action === "moveOption" ? { fromOptionId, toOptionId }
: action === "allocate" ? { setId, groupId }
: {};
setBusy(true);
try {
const res = await apiFetch<BulkResponse>("/api/registrations/bulk", { method: "POST", authToken: token, body: { action, registrationIds: selected.map(r => r.id), ...params } });
setResult(res);
onDone();
} catch (e) {
setError(errorMessage(e) || "Couldn't do that");
} finally {
setBusy(false);
}
};
const ready = !busy && !needsOneEvent && (
action === "export" || action === "cancel"
|| (action === "markPaid" && !!method)
|| (action === "message" && text.trim() && (channel === "whatsapp" || subject.trim()))
|| (action === "moveOption" && fromOptionId && toOptionId && fromOptionId !== toOptionId)
|| (action === "allocate" && !!groupKey)
);
const nameOf = (id: string) => selected.find(r => r.id === id)?.user?.name || id.slice(0, 8);
return (
<div className="border border-brand-200 bg-brand-50/60 rounded-xl p-3 mb-3 space-y-3" data-testid="bulk-bar">
<div className="flex flex-wrap items-center gap-2">
<CheckSquare className="w-4 h-4 text-brand-700" />
<span className="text-sm font-medium text-brand-900">{selected.length} selected</span>
<select aria-label="Bulk action" className="border rounded px-2 py-1.5 text-sm bg-white" value={action} onChange={e => { setAction(e.target.value as Action); setResult(null); setError(null); }}>
<option value="">Choose an action…</option>
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<button type="button" onClick={onClear} className="ml-auto inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><X className="w-3.5 h-3.5" /> Clear selection</button>
</div>
{needsOneEvent && <p className="text-xs text-amber-800">Tick registrations from one event only (filter by event) to {action === "moveOption" ? "move tickets" : "put people in a group"}.</p>}
{action === "message" && (
<div className="space-y-2">
<div className="flex gap-3 text-sm">
<label className="flex items-center gap-1"><input type="radio" checked={channel === "email"} onChange={() => setChannel("email")} /> Email</label>
<label className="flex items-center gap-1"><input type="radio" checked={channel === "whatsapp"} onChange={() => setChannel("whatsapp")} /> WhatsApp</label>
</div>
{channel === "email" && <input aria-label="Subject" className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Subject" value={subject} onChange={e => setSubject(e.target.value)} />}
<textarea aria-label="Message" rows={4} className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Hi {name}, …" value={text} onChange={e => setText(e.target.value)} />
<p className="text-[11px] text-gray-500">Sent as an event update to the person who registered. You can use {"{name}"}, {"{eventTitle}"} and {"{eventStart}"}.</p>
</div>
)}
{action === "markPaid" && (
<label className="text-sm flex items-center gap-2">Paid by
<select aria-label="Paid by" className="border rounded px-2 py-1.5 text-sm bg-white" value={method} onChange={e => setMethod(e.target.value)}>
<option value="cash">Cash</option><option value="eft">EFT</option><option value="card">Card (card machine)</option><option value="other">Other</option>
</select>
<span className="text-xs text-gray-500">Records what each still owes (about R {totalOwing.toFixed(2)} in all).</span>
</label>
)}
{action === "moveOption" && oneEvent && (
<div className="flex flex-wrap gap-2 items-center text-sm">
<select aria-label="Move from" className="border rounded px-2 py-1.5 bg-white" value={fromOptionId} onChange={e => setFromOptionId(e.target.value)}>
<option value="">From ticket…</option>{options.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
→
<select aria-label="Move to" className="border rounded px-2 py-1.5 bg-white" value={toOptionId} onChange={e => setToOptionId(e.target.value)}>
<option value="">To ticket…</option>{options.filter(o => o.id !== fromOptionId).map(o => <option key={o.id} value={o.id}>{o.name} (R{Number(o.price).toFixed(0)})</option>)}
</select>
</div>
)}
{action === "allocate" && oneEvent && (
sets.length === 0 ? <p className="text-xs text-gray-600">This event has no rooms or groups yet — set them up under Rooms &amp; groups.</p> : (
<select aria-label="Group" className="border rounded px-2 py-1.5 text-sm bg-white" value={groupKey} onChange={e => setGroupKey(e.target.value)}>
<option value="">Choose a group…</option>
{sets.map(s => <optgroup key={s.id} label={s.name}>{s.groups.map(g => <option key={g.id} value={`${s.id}::${g.id}`}>{g.name}</option>)}</optgroup>)}
</select>
)
)}
{action === "cancel" && <p className="text-xs text-gray-700">Cancelling doesn&apos;t refund anyone — record refunds on the Payments page.</p>}
{action && (
<button type="button" disabled={!ready} onClick={run} className={`inline-flex items-center gap-1.5 px-4 py-1.5 text-sm rounded font-medium disabled:opacity-50 ${action === "cancel" ? "bg-red-600 text-white hover:bg-red-700" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
{action === "export" && <Download className="w-4 h-4" />}
{busy ? "Working…" : `${ACTIONS.find(a => a[0] === action)?.[1]} — ${selected.length}`}
</button>
)}
{error && <div className="p-2 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{result && (
<div className="p-2 border rounded bg-white text-sm" role="status" data-testid="bulk-result">
<div><strong>{result.done}</strong> done{result.skipped ? `, ${result.skipped} skipped` : ""}{result.failed ? `, ${result.failed} not done` : ""}.</div>
{result.results.filter(r => !r.ok || r.skipped).length > 0 && (
<ul className="mt-1 text-xs text-gray-700 max-h-40 overflow-auto">
{result.results.filter(r => !r.ok || r.skipped).map(r => <li key={r.id}><span className={r.ok ? "text-gray-500" : "text-red-700"}>{nameOf(r.id)}</span>: {r.message}</li>)}
</ul>
)}
</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&apos;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>&lt;t&gt;.&lt;body&gt;</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&apos;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&apos;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>
);
}
@@ -28,7 +28,7 @@ type ParentalRow = { id: string; at: string | null; child: string; event: string
type LogResponse<T> = { rows: T[]; total: number; pageSize: number };
const SOURCE: Record<string, string> = {
signup: "Sign-up", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
signup: "Sign-up", activation: "Account activation", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
whatsapp_stop: "Replied STOP", existing_account: "Existing account (1.38.0)", admin: "Administrator",
};
@@ -2,13 +2,15 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine } from "lucide-react";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine, Sparkles } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[] };
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
type BuiltInUpdate = { revision: string; summary: string[]; sections: string[] };
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[]; builtInUpdates?: BuiltInUpdate[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const PLACEHOLDER_HELP: [string, string][] = [
@@ -106,10 +108,45 @@ export function NoticeEditor({ token }: { token: string }) {
}
};
// The built-in text changed since this site last looked: hide the list once the admin has been through it.
const markReviewed = async () => {
setBusy(true);
try {
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
setDraft(d => (d ? { ...d, builtInUpdates: [] } : d));
setMsg({ type: "ok", text: "Done. Remember to save the draft and publish if you took any new text." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setBusy(false);
}
};
if (!draft) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const builtInById = new Map(draft.builtIn.map(s => [s.id, s]));
const current = draft.versions[0];
const updates = draft.builtInUpdates || [];
const updatedIds = new Set(updates.flatMap(u => u.sections));
const differs = (s: NoticeSection) => { const b = builtInById.get(s.id); return !!b && (b.body !== s.body || b.title !== s.title); };
// Built-in sections this notice doesn't have (removed, or new in the built-in text).
const missing = draft.builtIn.filter(b => !sections.some(s => s.id === b.id));
const pendingIds = sections.filter(s => updatedIds.has(s.id) && differs(s)).map(s => s.id);
/** Puts a built-in section back where it sits in the built-in text. */
const addBuiltIn = (b: NoticeSection) => {
const order = draft.builtIn.map(x => x.id);
const after = order.slice(0, order.indexOf(b.id)).reverse().find(id => sections.some(s => s.id === id));
const at = after ? sections.findIndex(s => s.id === after) + 1 : 0;
change([...sections.slice(0, at), { ...b }, ...sections.slice(at)]);
};
const useAllNew = () => {
if (!confirm("Replace every changed section with the new built-in text? Your own wording in those sections is replaced (other sections stay as they are). Nothing is public until you publish.")) return;
const next = sections.map(s => {
const b = updatedIds.has(s.id) ? builtInById.get(s.id) : undefined;
return b ? { ...b } : s;
});
change(next);
};
return (
<div className="space-y-5">
@@ -148,6 +185,24 @@ export function NoticeEditor({ token }: { token: string }) {
{dirty && <span className="self-center text-xs text-amber-700">Unsaved changes</span>}
</div>
{updates.length > 0 && (
<div className="border border-amber-200 bg-amber-50 rounded-xl p-4 text-sm text-amber-900 space-y-2" data-testid="builtin-updates">
<div className="flex items-center gap-2 font-medium"><Sparkles className="w-4 h-4" /> The built-in privacy notice has been updated</div>
<p>Your published notice keeps your own text, so the new wording from Cross Events {updates.map(u => u.revision).join(", ")} isn&apos;t in it yet:</p>
<ul className="list-disc pl-5">{updates.flatMap(u => u.summary).map((line, i) => <li key={i}>{line}</li>)}</ul>
<p>
{pendingIds.length > 0
? <>Sections with new text are marked below. Use the new text where you want it (it replaces your wording in that section), then save the draft and publish.</>
: <>Your draft already has the new text in every changed section. Save the draft and publish to make it live.</>}
{" "}Tick &quot;Significant change&quot; when publishing if people should accept it again.
</p>
<div className="flex flex-wrap gap-2">
{pendingIds.length > 0 && <button onClick={useAllNew} disabled={busy} className="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-sm font-medium hover:bg-amber-700 disabled:opacity-50">Use all the new text</button>}
<button onClick={markReviewed} disabled={busy} className="px-3 py-1.5 rounded-lg border border-amber-300 bg-white text-sm hover:bg-amber-100 disabled:opacity-50">Done — hide this</button>
</div>
</div>
)}
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{publishing && (
@@ -182,8 +237,9 @@ export function NoticeEditor({ token }: { token: string }) {
<div className="space-y-3">
{sections.map((s, i) => {
const original = builtInById.get(s.id);
const hasNew = updatedIds.has(s.id) && differs(s);
return (
<div key={`${s.id}-${i}`} className="border rounded-xl p-3 bg-white">
<div key={`${s.id}-${i}`} className={`border rounded-xl p-3 bg-white ${hasNew ? "border-amber-300" : ""}`} data-testid={hasNew ? "section-has-new-text" : undefined}>
<div className="flex items-center gap-2 mb-2">
<input className={`${inputCls} font-medium`} value={s.title} onChange={e => update(i, { title: e.target.value })} aria-label="Section title" />
<button onClick={() => move(i, -1)} disabled={i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
@@ -194,9 +250,27 @@ export function NoticeEditor({ token }: { token: string }) {
<button onClick={() => confirm(`Remove "${s.title}"?`) && change(sections.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label="Remove section"><Trash2 className="w-4 h-4" /></button>
</div>
<textarea className={`${inputCls} font-mono text-xs`} rows={Math.min(18, Math.max(3, s.body.split("\n").length + 1))} value={s.body} onChange={e => update(i, { body: e.target.value })} aria-label={`${s.title} text`} />
{hasNew && original && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-amber-800 font-medium">New built-in text for this section — show it</summary>
<pre className="mt-2 whitespace-pre-wrap rounded-lg bg-amber-50 border border-amber-200 p-2 font-mono text-[11px] text-gray-700">{original.body}</pre>
<button onClick={() => update(i, { title: original.title, body: original.body })} className="mt-2 px-3 py-1.5 rounded-lg bg-amber-600 text-white hover:bg-amber-700">Use the new text</button>
</details>
)}
</div>
);
})}
{missing.length > 0 && (
<div className="border border-dashed rounded-xl p-3 text-sm text-gray-600 space-y-1">
<div className="font-medium text-gray-800">Built-in sections not in your notice</div>
{missing.map(b => (
<div key={b.id} className="flex items-center justify-between gap-2">
<span>{b.title}{updatedIds.has(b.id) && <span className="ml-1 text-xs text-amber-700">(new text)</span>}</span>
<button onClick={() => addBuiltIn(b)} className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"><Plus className="w-3.5 h-3.5" /> Add</button>
</div>
))}
</div>
)}
<button onClick={() => change([...sections, { id: `section-${sections.length + 1}`, title: "New section", body: "" }])} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add section
</button>
@@ -0,0 +1,122 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CalendarClock, ArrowRight, Baby, HandHeart, UserCheck, Banknote, Inbox, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { Event } from "@/types";
type TodayEvent = {
id: string; title: string; location: string | null; campus: Event["campus"]; isHidden: boolean; kidsCheckIn: boolean;
startsAt: string; endsAt: string; sessionLabel: string | null;
places: number; checkedIn: number; kidsIn: number | null;
volunteers: { needed: number; rostered: number; checkedIn: number } | null;
};
type Pending = { key: string; label: string; count: number; href: string; tone: string };
type Today = { events: TodayEvent[]; money: { today: number; todayCount: number; week: number } | null; pending: Pending[] };
const TONE: Record<string, string> = {
amber: "bg-amber-50 text-amber-800", rose: "bg-rose-50 text-rose-700", blue: "bg-blue-50 text-blue-700",
violet: "bg-violet-50 text-violet-700", green: "bg-green-50 text-green-700",
};
const time = (d: string) => new Date(d).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const rand = (n: number) => `R ${n.toFixed(2)}`;
/**
* The top of every staff dashboard (1.49.0): what's on today and how it's going, and — for
* supervisors and admins — money in and what's waiting for someone, each linking to where it's
* dealt with. Refreshes every 30 seconds while the tab is visible.
*/
export function TodayPanel({ supervisor }: { supervisor: boolean }) {
const { token } = useAuth();
const [data, setData] = useState<Today | null>(null);
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<Today>("/api/dashboard/today", { authToken: token })); } catch { /* keep what's shown */ }
}, [token]);
useEffect(() => { load(); }, [load]);
useVisiblePolling(load, 30000, !!token);
if (!data) return null;
return (
<div className={`grid gap-6 mb-6 ${supervisor ? "lg:grid-cols-3" : ""}`} data-testid="today-panel">
<div className={`border rounded-xl p-4 bg-white shadow-sm min-w-0 ${supervisor ? "lg:col-span-2" : ""}`}>
<div className="flex items-center gap-2 mb-3">
<CalendarClock className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold">Today</h2>
<span className="text-xs text-gray-500">{new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}</span>
</div>
{data.events.length === 0 ? (
<p className="text-sm text-gray-500">Nothing on today.</p>
) : (
<ul className="divide-y" data-testid="today-events">
{data.events.map(e => {
const pct = e.places ? Math.min(100, Math.round((e.checkedIn / e.places) * 100)) : 0;
return (
<li key={e.id} className="py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-gray-900 flex items-center gap-2 flex-wrap">
{e.title}{e.sessionLabel && <span className="text-xs font-normal text-gray-500">· {e.sessionLabel}</span>}
<CampusBadge campus={e.campus} />
</div>
<div className="text-xs text-gray-500">{time(e.startsAt)}–{time(e.endsAt)}{e.location ? ` · ${e.location}` : ""}</div>
</div>
<Link href={e.kidsCheckIn ? "/dashboard/staff/kids" : `/dashboard/staff/ticket-scanning`} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline shrink-0">
{e.kidsCheckIn ? "Kids desk" : "Scanner"} <ArrowRight className="w-3 h-3" />
</Link>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{!e.kidsCheckIn && (
<span className="inline-flex items-center gap-1.5">
<UserCheck className="w-3.5 h-3.5" /> {e.checkedIn} of {e.places} checked in
<span className="inline-block w-20 h-1.5 rounded bg-gray-100 overflow-hidden"><span className="block h-full bg-brand-500" style={{ width: `${pct}%` }} /></span>
</span>
)}
{e.kidsIn !== null && <span className="inline-flex items-center gap-1"><Baby className="w-3.5 h-3.5" /> {e.kidsIn} children in now</span>}
{e.volunteers && (
<span className="inline-flex items-center gap-1"><HandHeart className="w-3.5 h-3.5" /> {e.volunteers.checkedIn} of {e.volunteers.rostered} volunteers here{e.volunteers.rostered < e.volunteers.needed ? ` (${e.volunteers.needed - e.volunteers.rostered} short)` : ""}</span>
)}
</div>
</li>
);
})}
</ul>
)}
{data.money && (
<div className="mt-4 pt-3 border-t flex flex-wrap gap-x-6 gap-y-1 text-sm">
<span className="inline-flex items-center gap-1.5"><Banknote className="w-4 h-4 text-green-600" /> Today <strong>{rand(data.money.today)}</strong> <span className="text-xs text-gray-500">({data.money.todayCount} payment{data.money.todayCount === 1 ? "" : "s"})</span></span>
<span>Last 7 days <strong>{rand(data.money.week)}</strong></span>
</div>
)}
</div>
{supervisor && (
<div className="border rounded-xl p-4 bg-white shadow-sm min-w-0">
<div className="flex items-center gap-2 mb-3">
<Inbox className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold">Waiting for you</h2>
</div>
{data.pending.length === 0 ? (
<p className="text-sm text-gray-500 inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4 text-green-600" /> All caught up.</p>
) : (
<ul className="space-y-2" data-testid="waiting">
{data.pending.map(p => (
<li key={p.key}>
<Link href={p.href} className="flex items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-gray-50">
<span className={`min-w-[2rem] text-center text-sm font-semibold rounded-md px-1.5 py-0.5 ${TONE[p.tone] || "bg-gray-100 text-gray-700"}`}>{p.count}</span>
<span className="text-sm text-gray-700 flex-1">{p.label}</span>
<ArrowRight className="w-3.5 h-3.5 text-gray-400" />
</Link>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+24 -9
View File
@@ -15,6 +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 { SavedFilters } from "@/components/shared/SavedFilters";
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
@@ -1512,15 +1513,29 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
className="mb-3"
id="reports-campus"
mine
/>
<div className="flex flex-wrap items-start gap-3 mb-3">
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
id="reports-campus"
mine
/>
<SavedFilters
page="reports"
value={{ report, events: selectedEventIds, from: dateFrom, to: dateTo, campus, past: showPastEvents, inactive: showInactiveEvents, closed: showClosedEvents }}
onApply={p => {
if (typeof p.report === "string" && REPORTS.some(r => r.key === p.report)) setReport(p.report as ReportKey);
setCampus(String(p.campus || ""));
setShowPastEvents(p.past === true); setShowInactiveEvents(p.inactive === true); setShowClosedEvents(p.closed !== false);
setSelectedEventIds(Array.isArray(p.events) ? p.events : []);
setDateFrom(String(p.from || "")); setDateTo(String(p.to || ""));
setReady(false);
}}
skipDefault={!!searchParams.get("report")}
/>
</div>
<ReportsShell
events={filteredEvents}
@@ -0,0 +1,150 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Calendar, User, Banknote, ClipboardList, CornerDownLeft, Zap, FileText, Settings, BarChart2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { matchPages, type CommandPage } from "@/lib/commandPages";
type Result = { type: "event" | "payment" | "person" | "registration"; id: string; title: string; subtitle: string; href: string };
type Item = { key: string; group: string; title: string; subtitle?: string; href: string; icon: React.ElementType };
const RESULT_GROUP: Record<Result["type"], [string, React.ElementType]> = {
event: ["Events", Calendar], payment: ["Payments", Banknote], person: ["People", User], registration: ["Registrations", ClipboardList],
};
const PAGE_ICON: Record<CommandPage["group"], React.ElementType> = { Actions: Zap, Pages: FileText, Settings, Reports: BarChart2 };
/** Opens with Ctrl+K / ⌘K (or the event below) for staff, supervisors and admins. */
export const OPEN_PALETTE_EVENT = "cross-events:open-palette";
/**
* Ctrl+K command palette (1.49.0): jump to a page, setting or report, start an action ("New
* event"), or find an event, a payment by amount ("R450"), a person or a registration — what the
* person may see. ↑/↓ to move, Enter to open, Esc to close.
*/
export function CommandPalette() {
const { user, token } = useAuth();
const router = useRouter();
const role = user?.role || "user";
const allowed = role === "staff" || role === "supervisor" || role === "admin";
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (!allowed) return;
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(o => !o);
}
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKey);
window.addEventListener(OPEN_PALETTE_EVENT, onOpen);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener(OPEN_PALETTE_EVENT, onOpen); };
}, [allowed]);
useEffect(() => {
if (open) { setQuery(""); setResults([]); setActive(0); setTimeout(() => inputRef.current?.focus(), 0); }
}, [open]);
// Search as they type (after a short pause); pages are matched here, instantly.
useEffect(() => {
if (!open || !token) return;
const q = query.trim();
if (q.length < 2) { setResults([]); return; }
const timer = setTimeout(() => {
apiFetch<{ results: Result[] }>(`/api/dashboard/search?q=${encodeURIComponent(q)}`, { authToken: token })
.then(r => setResults(r.results || []))
.catch(() => setResults([]));
}, 200);
return () => clearTimeout(timer);
}, [query, open, token]);
const items = useMemo<Item[]>(() => {
const pages = matchPages(role, query).slice(0, query.trim() ? 8 : 10).map(p => ({ key: `page:${p.href}`, group: p.group, title: p.label, href: p.href, icon: PAGE_ICON[p.group] }));
const found = results.map(r => ({ key: `${r.type}:${r.id}`, group: RESULT_GROUP[r.type][0], title: r.title, subtitle: r.subtitle, href: r.href, icon: RESULT_GROUP[r.type][1] }));
return query.trim() ? [...found, ...pages] : pages;
}, [role, query, results]);
useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))); }, [items.length]);
useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]);
const go = useCallback((item: Item | undefined) => {
if (!item) return;
setOpen(false);
router.push(item.href);
}, [router]);
if (!allowed || !open) return null;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
else if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(items.length - 1, a + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(0, a - 1)); }
else if (e.key === "Enter") { e.preventDefault(); go(items[active]); }
};
let lastGroup = "";
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center bg-black/40 p-4 pt-[12vh]" onMouseDown={() => setOpen(false)}>
<div role="dialog" aria-modal="true" aria-label="Go to or search" className="w-full max-w-xl rounded-xl bg-white shadow-2xl overflow-hidden" onMouseDown={e => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b px-3">
<Search className="w-4 h-4 text-gray-400 shrink-0" />
<input
ref={inputRef} value={query} onChange={e => { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown}
placeholder={role === "admin" ? "Go to a page, or find an event, person, payment (R450)…" : role === "supervisor" ? "Go to a page, or find an event or payment (R450)…" : "Go to a page or find an event…"}
className="flex-1 py-3 text-sm outline-none bg-transparent" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="palette-results"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
/>
<kbd className="text-[10px] text-gray-400 border rounded px-1.5 py-0.5">Esc</kbd>
</div>
<ul id="palette-results" ref={listRef} role="listbox" className="max-h-[55vh] overflow-y-auto py-1" data-testid="palette-results">
{items.length === 0 && <li className="px-4 py-6 text-sm text-gray-500 text-center">{query.trim().length < 2 ? "Type to search." : "Nothing found."}</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{header && <li className="px-4 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400" role="presentation">{header}</li>}
<li
id={`palette-item-${i}`} data-index={i} role="option" aria-selected={i === active}
className={`mx-1 flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer ${i === active ? "bg-brand-50" : "hover:bg-gray-50"}`}
onMouseMove={() => setActive(i)} onClick={() => go(item)}
>
<Icon className="w-4 h-4 text-gray-500 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block text-sm text-gray-900 truncate">{item.title}</span>
{item.subtitle && <span className="block text-xs text-gray-500 truncate">{item.subtitle}</span>}
</span>
{i === active && <CornerDownLeft className="w-3.5 h-3.5 text-gray-400" />}
</li>
</React.Fragment>
);
})}
</ul>
<div className="border-t px-4 py-2 text-[11px] text-gray-400 flex gap-4">
<span>↑↓ to move</span><span>Enter to open</span><span>Ctrl K to open or close</span>
</div>
</div>
</div>
);
}
/** A button that opens the palette (for people who don't use the keyboard shortcut). */
export function CommandPaletteButton({ className = "" }: { className?: string }) {
const { user } = useAuth();
if (!user || !["staff", "supervisor", "admin"].includes(user.role || "")) return null;
return (
<button type="button" onClick={() => window.dispatchEvent(new Event(OPEN_PALETTE_EVENT))}
className={`inline-flex items-center gap-2 rounded-lg border bg-white px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 ${className}`}>
<Search className="w-4 h-4" /> Search or jump to… <kbd className="ml-2 text-[10px] border rounded px-1.5 py-0.5">Ctrl K</kbd>
</button>
);
}
@@ -9,11 +9,12 @@ import { errorMessage } from "@/lib/utils";
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
/**
* POPIA: a privacy notice version marked as a significant change has been published, and
* this person accepted an older one. Asked once after login; it can't be waved away, but the
* notice and the Privacy page (to delete the account instead) stay reachable.
* POPIA: asked once after login when this person never accepted the privacy notice (an account
* made for them, or from before acceptances were recorded) — reason "first" — or a version marked
* as a significant change came out after the one they accepted ("updated"). It can't be waved
* away, but the notice and the Privacy page (to delete the account instead) stay reachable.
*/
export function NoticePrompt({ onDone }: { onDone: () => void }) {
export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first" | "updated"; onDone: () => void }) {
const { token } = useAuth();
const [notice, setNotice] = useState<PublicNotice | null>(null);
const [saving, setSaving] = useState(false);
@@ -40,7 +41,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
};
// The newest version that needed accepting, for "what changed".
const changes = notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary;
const changes = reason === "updated" ? notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary : null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="notice-prompt-title">
@@ -49,7 +50,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
<ScrollText className="w-5 h-5" />
</div>
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">We&apos;ve updated our privacy notice</h2>
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">{reason === "first" ? "Our privacy notice" : <>We&apos;ve updated our privacy notice</>}</h2>
</div>
{changes && (
<div className="mb-3 p-3 rounded-lg bg-gray-50 border text-sm text-gray-700 whitespace-pre-line">
@@ -57,6 +58,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
</div>
)}
<p className="text-sm text-gray-600 mb-5">
{reason === "first" && <>It explains what we keep about you and why, and your rights. </>}
Please read the <Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">privacy notice</Link> and
accept it to carry on using your account. If you don&apos;t agree, you can{" "}
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">delete your account</Link> instead.
@@ -42,7 +42,8 @@ export function PrivacyNoticeSection({ section, first = false }: { section: Noti
) : (
<h3 className="text-xl font-semibold text-brand-600 mb-3">{section.title}</h3>
)}
{section.id === "cookies" && (
{/* Above the cookie section: the built-in "cookies" one, or one an admin wrote about cookies. */}
{/cookie/i.test(section.id) && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
@@ -18,6 +18,6 @@ export function PrivacyPrompts() {
const finishNotice = useCallback(() => setNoticeDone(true), []);
if (pathname === "/dashboard/user/privacy") return null;
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt onDone={finishNotice} />;
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt reason={user.noticePromptPending} onDone={finishNotice} />;
return <NewsPrompt />;
}
@@ -0,0 +1,116 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Star, Trash2, Check } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
export type FilterValues = Record<string, string | number | boolean | string[]>;
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
/**
* Saved filters on a list page (1.49.2): apply one with a click, save the current filters under a
* name, star one as the default (applied when the page opens, unless the link already says what
* to show — `skipDefault`). Stored per person on the server.
*/
export function SavedFilters({ page, value, onApply, skipDefault = false, className = "" }: {
page: "registrations" | "users" | "events" | "reports";
value: FilterValues;
onApply: (params: FilterValues) => void;
skipDefault?: boolean;
className?: string;
}) {
const { token } = useAuth();
const [list, setList] = useState<Saved[]>([]);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [asDefault, setAsDefault] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applied, setApplied] = useState<string | null>(null);
const defaultDone = useRef(false);
const boxRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
setList(rows);
// The default, once, when the page opens.
if (!defaultDone.current) {
defaultDone.current = true;
const def = rows.find(r => r.isDefault);
if (def && !skipDefault) { onApply(def.params); setApplied(def.id); }
}
} catch { /* the page works without them */ }
// onApply/skipDefault are read once, for the default.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, page]);
useEffect(() => { load(); }, [load]);
// Close when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const act = async (fn: () => Promise<unknown>) => {
setError(null);
try { await fn(); await load(); } catch (e) { setError(errorMessage(e) || "Couldn't save that"); }
};
const save = () => act(async () => {
setSaving(true);
try {
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
setApplied(row.id); setName(""); setAsDefault(false);
} finally { setSaving(false); }
});
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
const remove = (f: Saved) => act(async () => {
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
});
const current = list.find(f => f.id === applied);
return (
<div className={`relative inline-block ${className}`} ref={boxRef} data-testid="saved-filters">
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50">
<Bookmark className="w-4 h-4" /> {current ? current.name : "Saved filters"}{list.length > 0 && !current ? ` (${list.length})` : ""}
</button>
{open && (
<div className="absolute z-30 mt-1 w-72 right-0 sm:left-0 sm:right-auto rounded-xl border bg-white shadow-lg p-2 space-y-2" role="dialog" aria-label="Saved filters">
{list.length === 0 ? (
<p className="text-xs text-gray-500 px-1">No saved filters yet. Set the filters the way you like them and save them here.</p>
) : (
<ul className="max-h-60 overflow-y-auto">
{list.map(f => (
<li key={f.id} className="flex items-center gap-1 rounded hover:bg-gray-50">
<button type="button" className="flex-1 text-left text-sm px-2 py-1.5 truncate" onClick={() => { onApply(f.params); setApplied(f.id); setOpen(false); }}>
{applied === f.id && <Check className="inline w-3.5 h-3.5 mr-1 text-brand-600" />}{f.name}
</button>
<button type="button" onClick={() => toggleDefault(f)} title={f.isDefault ? "Default — click to stop" : "Make this the default when the page opens"} aria-label={f.isDefault ? `${f.name} is the default` : `Make ${f.name} the default`} className="p-1">
<Star className={`w-4 h-4 ${f.isDefault ? "fill-amber-400 text-amber-500" : "text-gray-300 hover:text-gray-500"}`} />
</button>
<button type="button" onClick={() => remove(f)} aria-label={`Delete ${f.name}`} className="p-1 text-gray-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<div className="border-t pt-2 space-y-1.5">
<label className="block text-xs text-gray-600" htmlFor={`save-filter-${page}`}>Save the current filters as</label>
<div className="flex gap-1.5">
<input id={`save-filter-${page}`} className="flex-1 min-w-0 border rounded px-2 py-1 text-sm" placeholder="e.g. Camp — unpaid" value={name} onChange={e => setName(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && name.trim()) save(); }} />
<button type="button" disabled={saving || !name.trim()} onClick={save} className="px-2.5 py-1 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Save</button>
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-600"><input type="checkbox" checked={asDefault} onChange={e => setAsDefault(e.target.checked)} /> Use when this page opens</label>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</div>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { ClipboardList, Filter, Ban } from "lucide-react";
import { ClipboardList, Filter, Ban, CheckSquare, Bookmark } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -26,6 +26,12 @@ export const dashboardAdminRegistrationsHelpContent: HelpContent = {
<div className="space-y-4">
<p>Click a row to expand it and see ticket options (with early-bird flags where applied), the payment history, and any submitted form responses.</p>
<div className="space-y-4">
<GuideItem icon={Bookmark} title="Saved filters" tone="amber">
Set the filters the way you use them, then Saved filters → name them and Save. Click one to apply it; the star makes it the one used when the page opens. They&apos;re yours (other people don&apos;t see them) and follow you to any device. The Users, Events and Reports pages have them too.
</GuideItem>
<GuideItem icon={CheckSquare} title="Doing many at once" tone="violet">
Tick registrations (or &quot;Select all shown&quot; after filtering) and choose an action: send a message (email or WhatsApp, as an event update), mark paid (records what each still owes, in cash, EFT, card or other — tickets go out by email), move to another ticket (their tickets and QR codes stay the same; the price follows the new ticket), put their named people in a room or group, export to Excel, or cancel. Each one follows the same rules as doing it one at a time, and you see what happened to each. Moving tickets and groups need registrations from one event.
</GuideItem>
<GuideItem icon={ClipboardList} title="Changing status" tone="brand">
Use the status dropdown inside the expanded row to move a registration between pending, confirmed, partially paid, paid, or cancelled.
</GuideItem>
@@ -1,5 +1,5 @@
import React from "react";
import { TrendingUp, Zap, Shield, Users, Calendar, BarChart2, Settings } from "lucide-react";
import { TrendingUp, Zap, Shield, Users, Calendar, BarChart2, Settings, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -22,6 +22,12 @@ export const dashboardAdminHelpContent: HelpContent = {
<div className="space-y-4">
<p>The top row and the sections below it summarise your events at a glance — no need to open Reports for a quick check.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today and Waiting for you" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far, children in at kids church, and volunteers here. Money in today and over the last week. &quot;Waiting for you&quot; lists what needs someone — unpaid registrations for the next two weeks, parental consent still outstanding, waitlists, cover requests and roster gaps (and for admins: cashups, payments to reconcile, privacy requests, failed webhooks); click one to deal with it. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event or a payment by amount (&quot;R450&quot;) — admins also find people and registrations.
</GuideItem>
<GuideItem icon={TrendingUp} title="Past month's KPIs" tone="brand">
Active events, revenue, donations, registrations, and tickets sold — each covers a rolling 30-ish days (today back one month), compared against the equivalent month before that, for an apples-to-apples comparison.
</GuideItem>
@@ -1,5 +1,5 @@
import React from "react";
import { QrCode, Ticket, Lock } from "lucide-react";
import { QrCode, Ticket, Lock, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -20,6 +20,12 @@ export const dashboardStaffHelpContent: HelpContent = {
<div className="space-y-4">
<p>Use Scan tickets to validate attendees at the door with your device&apos;s camera, and Event tickets & printing to browse an event&apos;s tickets or print a list.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far and children in at kids church. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event.
</GuideItem>
<GuideItem icon={QrCode} title="Scans today / My scans / Last hour" tone="brand">
Live counts of ticket scans — across everyone, just you, and the last hour.
</GuideItem>
@@ -1,5 +1,5 @@
import React from "react";
import { TrendingUp, Zap, QrCode, UserPlus, BarChart2, DoorOpen } from "lucide-react";
import { TrendingUp, Zap, QrCode, UserPlus, BarChart2, DoorOpen, CalendarClock, Search } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -21,6 +21,12 @@ export const dashboardSupervisorHelpContent: HelpContent = {
<div className="space-y-4">
<p>The top row and the sections below it summarise your events at a glance — no need to open Reports for a quick check.</p>
<div className="space-y-4">
<GuideItem icon={CalendarClock} title="Today and Waiting for you" tone="blue">
What&apos;s on today — each event or session with how many people are checked in so far, children in at kids church, and volunteers here. Money in today and over the last week. &quot;Waiting for you&quot; lists what needs someone — unpaid registrations for the next two weeks, parental consent still outstanding, waitlists, cover requests and roster gaps (and for admins: cashups, payments to reconcile, privacy requests, failed webhooks); click one to deal with it. It refreshes every 30 seconds.
</GuideItem>
<GuideItem icon={Search} title="Search or jump to — Ctrl K" tone="violet">
Press Ctrl K (⌘K on a Mac) anywhere in the dashboard, or use the button at the top: type a page, setting or report and press Enter, start an action like &quot;New event&quot;, or find an event or a payment by amount (&quot;R450&quot;) — admins also find people and registrations.
</GuideItem>
<GuideItem icon={TrendingUp} title="Past month's KPIs" tone="brand">
Active events, revenue, donations, registrations, and tickets sold — each covers a rolling 30-ish days (today back one month), compared against the equivalent month before that, for an apples-to-apples comparison.
</GuideItem>
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle, Sparkles, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -79,6 +79,12 @@ export const adminPrivacyHelpContent: HelpContent = {
<GuideItem icon={ScrollText} title="Editing" tone="brand">
The notice starts from the built-in text. Change any section, add or remove sections, and save a draft — nothing is public until you publish. Placeholders like {"{org}"}, {"{ioName}"} and {"{operators}"} are filled in from Site settings and the operator register, so they stay up to date.
</GuideItem>
<GuideItem icon={Sparkles} title="When the built-in text is updated" tone="violet">
Until you publish your own version, the site shows the built-in text, so updates appear by themselves. Once you have published, your text stays as it is — after an update a yellow box lists what changed and marks the sections with new text. Show it, use it section by section (or all at once), save and publish. &quot;Done — hide this&quot; when you&apos;ve been through it.
</GuideItem>
<GuideItem icon={Users} title="How people agree to it" tone="blue">
At sign-up, and when they activate an account that was made for them (by staff, at the door or by whoever registered them). Anyone who has never agreed is asked once after they log in. Every agreement is in the consent log.
</GuideItem>
<GuideItem icon={Send} title="Publishing" tone="green">
Each publish is a numbered version with its date, and earlier versions stay readable on the privacy page. Tick &quot;Significant change&quot; and say what changed when people should accept it again — they&apos;re asked once, after they next log in. Leave it unticked for small fixes.
</GuideItem>
+1 -1
View File
@@ -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), or use Series: year on year to compare a whole series&apos; 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&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Saved filters (next to the campus filter) keep a report with its events, dates and options under a name — star one to have it ready when the page opens. 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&apos; 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&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+22 -1
View File
@@ -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">
&quot;+ Add webhook&quot;: 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">
&quot;Send test&quot; 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; &quot;Resend&quot; 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&apos;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",
+2 -1
View File
@@ -12,7 +12,8 @@ export type AuthUser = {
// POPIA: hasn't answered the "news and announcements" opt-in question yet (asked once).
newsPromptPending?: boolean;
// POPIA: a privacy notice version they must accept has been published since they last did.
noticePromptPending?: boolean;
// Why the privacy notice must be accepted after login: never accepted, or a newer version needs it.
noticePromptPending?: false | "first" | "updated";
// Multi-campus: their home campus (the events page starts on it).
homeCampusId?: string | null;
// Campus-scoped staff: the campuses they're limited to (null = every campus).
+68
View File
@@ -0,0 +1,68 @@
// What the Ctrl+K command palette can jump to without searching (1.49.0): pages, settings tabs,
// reports and quick actions, each for the roles that can open it. `keywords` widen matching.
export type Role = "staff" | "supervisor" | "admin";
export type CommandPage = { label: string; href: string; group: "Actions" | "Pages" | "Settings" | "Reports"; roles: Role[]; keywords?: string };
const S: Role[] = ["staff", "supervisor", "admin"];
const SUP: Role[] = ["supervisor", "admin"];
const ADM: Role[] = ["admin"];
export const COMMAND_PAGES: CommandPage[] = [
// Actions
{ group: "Actions", label: "New event", href: "/dashboard/supervisor/events?new=1", roles: SUP, keywords: "create add event" },
{ group: "Actions", label: "Register someone (manual registration)", href: "/dashboard/supervisor/manual", roles: SUP, keywords: "add registration walk-in guest" },
{ group: "Actions", label: "Scan tickets", href: "/dashboard/staff/ticket-scanning", roles: S, keywords: "scanner check in qr door" },
{ group: "Actions", label: "Kids check-in desk", href: "/dashboard/staff/kids", roles: S, keywords: "children pickup code" },
{ group: "Actions", label: "Record a payment", href: "/dashboard/supervisor/payments", roles: SUP, keywords: "cash eft donation pay" },
{ group: "Actions", label: "Email attendees", href: "/dashboard/supervisor/email-attendees", roles: SUP, keywords: "message send mail" },
{ group: "Actions", label: "WhatsApp attendees", href: "/dashboard/supervisor/whatsapp-attendees", roles: SUP, keywords: "message send" },
// Pages
{ group: "Pages", label: "Events", href: "/dashboard/supervisor/events", roles: SUP, keywords: "manage edit copy" },
{ group: "Pages", label: "Event series", href: "/dashboard/supervisor/series", roles: SUP, keywords: "season pass follow" },
{ group: "Pages", label: "Registrations", href: "/dashboard/admin/registrations", roles: ADM, keywords: "orders cancel status" },
{ group: "Pages", label: "Users", href: "/dashboard/admin/users", roles: ADM, keywords: "people accounts roles" },
{ group: "Pages", label: "At the door", href: "/dashboard/supervisor/at-the-door", roles: SUP, keywords: "walk-ins print" },
{ group: "Pages", label: "Payments & donations", href: "/dashboard/supervisor/payments", roles: SUP, keywords: "reconcile yoco payfast" },
{ group: "Pages", label: "Cashup", href: "/dashboard/admin/cashup", roles: ADM, keywords: "close costs reconcile takings" },
{ group: "Pages", label: "Rooms & groups", href: "/dashboard/supervisor/allocations", roles: SUP, keywords: "allocations cabins buses" },
{ group: "Pages", label: "Attendance register", href: "/dashboard/supervisor/attendance", roles: S, keywords: "sessions certificates" },
{ group: "Pages", label: "Waitlist", href: "/dashboard/supervisor/waitlist", roles: S, keywords: "sold out offers" },
{ group: "Pages", label: "Parental consent", href: "/dashboard/supervisor/consent", roles: S, keywords: "youth medical" },
{ group: "Pages", label: "Volunteer rosters", href: "/dashboard/supervisor/volunteers", roles: SUP, keywords: "serving roles cover" },
{ group: "Pages", label: "Volunteers today", href: "/dashboard/staff/volunteers", roles: S, keywords: "serving check-in qr" },
{ group: "Pages", label: "Kids check-in setup", href: "/dashboard/supervisor/kids", roles: SUP, keywords: "rooms ratios" },
{ group: "Pages", label: "Kids room screen", href: "/dashboard/staff/kids/rooms", roles: S, keywords: "children leaders on duty" },
{ group: "Pages", label: "Coupon codes", href: "/dashboard/supervisor/coupons", roles: SUP, keywords: "discount" },
{ group: "Pages", label: "Bundle deals", href: "/dashboard/supervisor/bundles", roles: SUP, keywords: "family deal" },
{ group: "Pages", label: "Event tickets & printing", href: "/dashboard/staff/event-tickets", roles: S, keywords: "print list" },
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/admin/forms", roles: ADM, keywords: "answers" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
// Settings
...([
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
// Reports
...([
["payments", "Payments between dates"], ["revenue", "Revenue summary"], ["regStatus", "Registration status"],
["funnel", "Registration funnel"], ["feedbackSummary", "Feedback summary"], ["usage", "Ticket usage"],
["financeReport", "Finance report"], ["seriesComparison", "Series: year on year"], ["discounts", "Discounts"],
] as const).map(([key, label]): CommandPage => ({ group: "Reports", label: `Report: ${label}`, href: `/dashboard/supervisor/reports?report=${key}`, roles: SUP, keywords: "report" })),
];
/** Pages for a role, matching every word of the query (in the label or keywords). */
export function matchPages(role: string, query: string): CommandPage[] {
const r = (["staff", "supervisor", "admin"].includes(role) ? role : null) as Role | null;
if (!r) return [];
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return COMMAND_PAGES.filter(p => p.roles.includes(r)).filter(p => {
const hay = `${p.label} ${p.keywords || ""} ${p.group}`.toLowerCase();
return words.every(w => hay.includes(w));
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.47.4",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.47.4",
"version": "1.49.2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.47.4",
"version": "1.49.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -11,7 +11,12 @@
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate",
"test:e2e": "playwright test",
"test": "npm --prefix backend test"
"test": "npm --prefix backend test",
"demo:list": "npm --prefix backend run set-demo -- --list",
"demo:small": "npm --prefix backend run set-demo -- --profile=small",
"demo:events": "npm --prefix backend run set-demo -- --profile=events",
"demo:ministry": "npm --prefix backend run set-demo -- --profile=ministry",
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus"
},
"keywords": [],
"author": "",
+2
View File
@@ -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",
},
},
{