Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dea712b72 | ||
|
|
f1714f9286 | ||
|
|
c0d01ce993 | ||
|
|
0414854dec | ||
|
|
dd537a1a16 | ||
|
|
2240c8a703 | ||
|
|
a7c8148588 | ||
|
|
96d1e67b85 | ||
|
|
e5d615e398 | ||
|
|
0253a1d5fa | ||
|
|
740897635f | ||
|
|
7ae0b6d23b | ||
|
|
6ef19d3290 | ||
|
|
51355069da | ||
|
|
3a96f9c1dc | ||
|
|
a7261db191 | ||
|
|
6045d1b05e | ||
|
|
5df04f5a4a | ||
|
|
35805f0c79 | ||
|
|
174022296b | ||
|
|
bc22a94f92 | ||
|
|
bbde6791cb | ||
|
|
e9a2ce9d01 | ||
|
|
39885a8719 |
@@ -1,129 +0,0 @@
|
||||
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
|
||||
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
|
||||
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
|
||||
#
|
||||
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
|
||||
# https://docs.gitea.com/usage/actions/act-runner
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Throwaway values for the test database and tokens — never production secrets.
|
||||
JWT_SECRET: ci-only-jwt-secret-not-for-production
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
|
||||
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- name: Migrations apply cleanly
|
||||
run: npx prisma migrate deploy
|
||||
working-directory: backend
|
||||
- run: npm test
|
||||
working-directory: backend
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx eslint src
|
||||
working-directory: frontend
|
||||
- run: npx tsc --noEmit -p .
|
||||
working-directory: frontend
|
||||
- run: npm run build
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
e2e:
|
||||
name: End-to-end tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: npm ci
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Keep the report when tests fail
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/report
|
||||
e2e/test-results
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy tagged release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, e2e]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ -z "$DEPLOY_HOST" ]; then
|
||||
echo "DEPLOY_HOST isn't set — skipping the deploy."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
|
||||
+206
@@ -7,6 +7,212 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
|
||||
who sees the roster and cover requests), following an event series, season passes, webhooks
|
||||
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
|
||||
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
|
||||
version show the new text straight away.
|
||||
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
|
||||
what changed in the built-in text since they last reviewed it, marks each section with new text
|
||||
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
|
||||
sections the notice doesn't have. **Done — hide this** records the review
|
||||
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
|
||||
"Significant change" if people should accept again) to make it live.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agreeing to the notice when an account was made for you** — activating an account (made by
|
||||
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
|
||||
Privacy Policy, as sign-up does (consent log source "Account activation").
|
||||
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
|
||||
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
|
||||
the built-in notice is in use. Before, they were only asked after a version marked as a
|
||||
significant change was published.
|
||||
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
|
||||
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
|
||||
|
||||
## [1.48.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
|
||||
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
|
||||
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
|
||||
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
|
||||
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
|
||||
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
|
||||
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
|
||||
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
|
||||
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
|
||||
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
|
||||
moment later, so a change that was rolled back sends nothing.
|
||||
- Safety and privacy: https addresses on public networks only (checked again at each send;
|
||||
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
|
||||
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
|
||||
days and with an anonymised account.
|
||||
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
|
||||
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
|
||||
|
||||
## [1.47.4] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Update requested, then a newer version came out** — the Licence page said the newer version
|
||||
was requested ("Version 1.47.3 is available. Update requested …") when the request was for an
|
||||
older one, and there was no way to ask for the newer version; the update banner stayed hidden.
|
||||
Now it shows the request ("You asked for version 1.47.0 on …") and, below it, that a newer
|
||||
version has come out with **Ask for 1.47.3 instead** (Withdraw request stays). The banner shows
|
||||
again and says to change the request. Changing it is audited as `hub_update_requested` with
|
||||
`changedFrom`.
|
||||
- With Cross Events Hub 1.5.0 the Hub moves the open request on to the new version and emails the
|
||||
provider once; with an older Hub the site withdraws the request and makes it again.
|
||||
|
||||
## [1.47.3] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Season passes** — one price for a ticket at each of several events in a series. On the Event
|
||||
series page supervisors make a pass (name, price, optional sale end) and tick which events and
|
||||
which ticket (and variant) at each it includes; the form shows what they'd cost separately. Youth
|
||||
(parental consent) events, staff-only tickets and per-session tickets can't be on a pass. Events
|
||||
and price are fixed once someone has bought it (switch it off instead); campus-scoped supervisors
|
||||
can only use their campuses' events.
|
||||
- **Buying** — on the series page signed-in people choose how many and buy: they're registered at
|
||||
every event on the pass at once and pay once (a free pass gives the tickets straight away). If
|
||||
online payment isn't possible the places stay booked and "Your passes" offers **Pay now**. A pass
|
||||
stops selling when its first event starts.
|
||||
- **Money per event** — the price is split over the events in proportion to their normal ticket
|
||||
prices (whole cents). Each registration's line is priced at its event's share, and the payment is
|
||||
recorded as one payment per event (all linked to the purchase), so each event's cashup and reports
|
||||
count only its part. Pass registrations aren't repriced by early-bird changes, take no coupon
|
||||
codes, and their tickets can't be edited.
|
||||
- `/api/season-passes` (list, create, change, delete; `:id/buy`; `purchases/:id/checkout`; `mine`),
|
||||
`passes` on `GET /api/series/by-slug/:slug`. Making, changing and buying passes is blocked in
|
||||
licence read-only mode. Purchases are in "Download my data". Audit action `season_pass_changed`
|
||||
(migration `20261028150000_season_pass`).
|
||||
|
||||
## [1.47.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
|
||||
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
|
||||
counted, donations applied to orders left out) and people checked in (a multi-session event shows
|
||||
the average per session), with the attendance rate and the change from the event before. Bar charts
|
||||
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
|
||||
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
|
||||
page opens it (`?report=seriesComparison&series=<id>`).
|
||||
- `GET /api/series/:id/compare`.
|
||||
|
||||
## [1.47.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
|
||||
one page. Supervisors make series on the new **Event series** page (name, web address,
|
||||
description, picture) and put an event in one with the **Series** box in the event editor; a
|
||||
copied event keeps its series. A series can have events at several campuses.
|
||||
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
|
||||
series shows "Part of …" and **Next in this series** on its page.
|
||||
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
|
||||
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
|
||||
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
|
||||
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
|
||||
series' followers.
|
||||
- Followed series are in "Download my data" and removed when an account is anonymised.
|
||||
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
|
||||
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
|
||||
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
|
||||
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
|
||||
`20261028140000_event_series`).
|
||||
|
||||
## [1.47.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Copy an event** — "Copy" on each card on the Events page opens the event editor filled in from
|
||||
that event: details, campus, sessions, ticket options, variants and early-bird tiers, sections,
|
||||
the registration form, notification recipients, the feedback questions, certificates and session
|
||||
reminders. Everything can be changed before saving. A **New start** box at the top moves every
|
||||
date by the same amount — the end, the sessions, the registration deadline, early-bird deadlines
|
||||
and the feedback date. On save the event's allocation groups (rooms, buses, teams) are copied
|
||||
too, and for admins its planned costs (a cost on a ticket option goes to the option with the
|
||||
same name). Not copied: registrations, coupons, attachments, bundles, the short link, the go-live
|
||||
time, cancelled sessions, kids check-in (kids ticket and rooms) and volunteer roles.
|
||||
- `POST /api/events/:id/copy-from` (blocked in licence read-only mode, campus-checked for both
|
||||
events); `POST /api/events` takes `clonedFromId`, recorded in the `event_created` audit entry.
|
||||
|
||||
## [1.46.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **What's new before you update** — Site settings → Licence shows the release notes for every
|
||||
version newer than the site, so you can decide whether to ask for the update. Updates that fix a
|
||||
security problem or a serious bug are marked, and show as a red banner.
|
||||
- **Withdraw request** — changed your mind after pressing Request update? Withdraw it on the Licence
|
||||
page and your provider is told not to update the site.
|
||||
- **Not now** — on the update banner or the Licence page: hides the "update available" reminder until
|
||||
the next version comes out ("Remind me again" turns it back on). Urgent updates can't be put off.
|
||||
- **Notices from your provider** — planned maintenance and other messages from your provider show as
|
||||
a banner at the top of the dashboard for site admins and supervisors, at the times given. Close
|
||||
one with ×; current notices are also listed on the Licence page.
|
||||
|
||||
- **`WHATS-NEW.md`** — the release notes churches see, in plain language. The Hub reads it from git
|
||||
with the version tags, so a release needs its section there before tagging.
|
||||
|
||||
Needs Cross Events Hub 1.4.0 for the notes, withdrawing and notices; with an older Hub the site
|
||||
works as before.
|
||||
|
||||
## [1.46.1] - 2026-10-28
|
||||
|
||||
### Removed
|
||||
|
||||
- The Gitea CI workflow (`.gitea/workflows/ci.yml`). There's no Actions runner, so every push
|
||||
left a run stuck on "Waiting". Tests and lint are run by hand before a release, and releases
|
||||
are deployed with `scripts/deploy.sh` on the server.
|
||||
|
||||
## [1.46.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Volunteer rosters** — plan who serves where (Supervisor → **Volunteer rosters**).
|
||||
- **Serving roles** (Welcome, Parking, Kids, Sound…) per event, or per session on a multi-session
|
||||
event, with the number of people needed, optional shift times and a short description. Each
|
||||
event or session has its own roster; nothing repeats by itself.
|
||||
- **Anyone with an account can volunteer:** My account → **Serving** lists events looking for
|
||||
people and lets you sign up for a role that isn't full. Supervisors can also put someone on a
|
||||
role (even beyond the number needed). Nobody can be in two roles at the same time. Supervisors
|
||||
get a sign-up link to share (it works for hidden events too).
|
||||
- **Reminders** by WhatsApp or email the day before a shift.
|
||||
- **"Can't make it" swaps:** the volunteer asks for cover, the event's other volunteers are told,
|
||||
and the first to accept takes the place straight away — no approval. The event's notification
|
||||
recipients get an email about every swap and every withdrawal.
|
||||
- **Check-in on the day:** volunteers scan the event's check-in QR code (show it on a screen or
|
||||
print it) with their phone camera and are checked in for that day's shifts; staff can tick
|
||||
people in on Staff → **Volunteers**, and undo it.
|
||||
- **Kids rooms:** a role can be linked to a kids room. Its volunteers count as leaders on duty
|
||||
in that room once they check in (for the room's ratio), and the room screen lists who is
|
||||
rostered but not in yet.
|
||||
- **Free volunteer ticket**, off by default: choose a ticket per event (or add a free
|
||||
"Volunteer" ticket) and everyone on the roster gets a free place on it unless they already have
|
||||
a ticket. It's given back when they leave the roster.
|
||||
- **Roster sheet** with phone numbers, check-in times and a printable view; fill rate per
|
||||
event; **hours served** per person across events.
|
||||
- Audit log entries for the volunteer ticket, role changes, people put on or taken off a roster
|
||||
and check-ins by hand; opening a roster is in the access log. "Download my data" includes
|
||||
your shifts; anonymising an account gives up its future shifts.
|
||||
- Background job `volunteers` (every 15 minutes, `VOLUNTEER_JOBS_ENABLED`) sends the reminders.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The kids check-in ticket was offered publicly:** the free "Kids check-in" ticket from 1.45.0
|
||||
showed on the event page and could be booked by anyone. It (and the new volunteer ticket) is now
|
||||
left out of public event pages and lists and refused on public registration; staff still see it.
|
||||
|
||||
## [1.45.1] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -310,24 +310,6 @@ and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
### Deploying from CI over SSH
|
||||
|
||||
The Gitea workflow deploys on a `v*` tag by SSHing to `DEPLOY_HOST`. Behind frp, that needs an
|
||||
SSH tunnel too, e.g. in `frpc.ini`:
|
||||
|
||||
```ini
|
||||
[hope-events-ssh]
|
||||
type = tcp
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 22
|
||||
remote_port = 6022
|
||||
```
|
||||
|
||||
Then use the VPS as `DEPLOY_HOST`. The workflow's `ssh` call (`.gitea/workflows/ci.yml`) doesn't
|
||||
take a port yet, so add `-p 6022` there (or a `DEPLOY_PORT` secret) before relying on it. Only
|
||||
open this if you need it: it exposes this server's SSH on the VPS. Use key-only login. Deploying
|
||||
by hand (above) needs none of this.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|
||||
+127
-20
@@ -29,14 +29,16 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Multi-campus](#multi-campus)
|
||||
17. [Kids check-in](#kids-check-in)
|
||||
18. [Accounts & security](#accounts--security)
|
||||
19. [Privacy (POPIA)](#privacy-popia)
|
||||
20. [Help system](#help-system)
|
||||
21. [Background jobs](#background-jobs)
|
||||
22. [Operations](#operations)
|
||||
23. [Page map](#page-map)
|
||||
16. [Event series](#event-series)
|
||||
17. [Multi-campus](#multi-campus)
|
||||
18. [Kids check-in](#kids-check-in)
|
||||
19. [Volunteer rosters](#volunteer-rosters)
|
||||
20. [Accounts & security](#accounts--security)
|
||||
21. [Privacy (POPIA)](#privacy-popia)
|
||||
22. [Help system](#help-system)
|
||||
23. [Background jobs](#background-jobs)
|
||||
24. [Operations](#operations)
|
||||
25. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -89,6 +91,14 @@ Feedback → Attendance**.
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Copy an event** — "Copy" on an event card opens the editor filled in from that
|
||||
event (details, campus, sessions, options, variants, early birds, sections, form,
|
||||
notification recipients, feedback questions, certificates, reminders). Setting
|
||||
the **New start** moves every date with it (end, sessions, registration deadline,
|
||||
early-bird deadlines, feedback date). Allocation groups are copied on save, and
|
||||
for admins the planned costs (matched to options by name). Not copied:
|
||||
registrations, coupons, attachments, the short link, the go-live time, kids
|
||||
check-in and volunteer roles.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Multi-day events (sessions)** — one event on several dates, e.g. every
|
||||
@@ -397,6 +407,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
and print an event's tickets.
|
||||
- **Kids check-in** (`/dashboard/staff/kids`) and the **room screen**
|
||||
(`/dashboard/staff/kids/rooms`) — see [Kids check-in](#kids-check-in).
|
||||
- **Volunteers** (`/dashboard/staff/volunteers`) — today's roster, ticking volunteers in and the
|
||||
check-in QR code — see [Volunteer rosters](#volunteer-rosters).
|
||||
|
||||
---
|
||||
|
||||
@@ -497,6 +509,7 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
| Other | Series: year on year (a series' events side by side — registrations, tickets, income, checked in, attendance rate, change from the one before) |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
@@ -528,6 +541,33 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Event series
|
||||
|
||||
Separate events grouped on one page — every year's camp, the monthly men's breakfast (each event
|
||||
keeps its own registrations; for one event on several dates see multi-day events).
|
||||
|
||||
- **Event series** page (supervisors): name, web address (`/series/<slug>`), description, picture;
|
||||
each series lists its events (campus-scoped supervisors see only their campuses' events in it)
|
||||
and its number of followers. Admins can delete a series (its events stay).
|
||||
- **Series box** in the event editor's Basic details. A copied event keeps its series. A series can
|
||||
have events at several campuses.
|
||||
- **Public series page**: upcoming events as cards, then past events. Event pages in a series show
|
||||
"Part of …" and **Next in this series** (the next public event in it).
|
||||
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
|
||||
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
|
||||
unsubscribe link). The `series` job runs every 15 minutes.
|
||||
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
|
||||
compares the series' events — registrations, tickets, income, checked in (average per session on
|
||||
multi-session events), attendance rate — each against the one before.
|
||||
- **Season passes**: on the Event series page, a pass gives a ticket (and variant) at each event
|
||||
ticked, at one price (optional sale end; stops when its first event starts; no youth events,
|
||||
staff-only or per-session tickets). Buyers choose how many on the series page and are registered at
|
||||
every event at once with one payment (or tickets at once when free; "Pay now" later if online
|
||||
payment isn't possible). The price is split over the events in proportion to their normal prices;
|
||||
each event gets its own registration line and payment for its share, so cashups stay per event.
|
||||
Pass registrations aren't repriced, take no codes and can't be edited. Events and price lock once
|
||||
sold.
|
||||
|
||||
## Multi-campus
|
||||
|
||||
For a church with several campuses in one install (Site settings → Campuses, off by
|
||||
@@ -594,6 +634,43 @@ label printers.
|
||||
changed only by parents, supervisors and admins (audited); opening the desk/room screens and
|
||||
offline downloads are in the access log; a child's profile is deleted after **6 months** without
|
||||
a check-in (Admin → Privacy → Retention).
|
||||
- **Volunteers in kids rooms:** a serving role linked to a room puts its volunteers on duty there
|
||||
when they check in; the room screen lists who is rostered but not in yet.
|
||||
- The kids ticket is never offered publicly (left out of public event pages, refused on public
|
||||
registration).
|
||||
|
||||
---
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Who serves where, for an event or each session.
|
||||
|
||||
- **Roles** (Supervisor → Volunteer rosters, `/dashboard/supervisor/volunteers`): name, people
|
||||
needed, optional shift times (default: the session's or event's times), description, and
|
||||
optionally a kids room. On a multi-session event each role is for one session. A role can't be
|
||||
deleted once someone has checked in for it.
|
||||
- **Volunteering** (My account → Serving, `/dashboard/user/serving`): anyone with an account signs
|
||||
up for a role that isn't full; supervisors can also put people on (beyond the number needed).
|
||||
Nobody can be in two roles whose shifts overlap. `?event=<id>` opens one event (the sign-up link
|
||||
supervisors share, also for hidden events). Volunteers are told when they're put on, taken off,
|
||||
or a role is deleted, and get a **reminder the day before**.
|
||||
- **Swaps:** "Can't make it" asks for cover — the event's other volunteers are told and the request
|
||||
shows on everyone's Serving page; the first to accept takes the place at once (no approval). The
|
||||
event's notification recipients (or its creator) are emailed about every swap and withdrawal.
|
||||
- **Check-in:** volunteers scan the event's **check-in QR code** (Check-in QR tab, or Staff →
|
||||
Volunteers) with their phone camera → `/serve/check-in` → every shift of theirs on that day is
|
||||
checked in. Staff can tick people in by hand (and undo). Check-in opens on the day of the shift
|
||||
and closes when it ends.
|
||||
- **Kids rooms:** checking in for a role linked to a kids room puts the volunteer on duty in that
|
||||
room (counted for the ratio, like "I'm on duty"); undoing the check-in takes them off.
|
||||
- **Volunteer ticket** (off by default): choose a main ticket of the event, or add a free
|
||||
"Volunteer" ticket. Volunteers without a ticket get a free place on it (in My Events); it's
|
||||
cancelled when they leave the roster (unless it was scanned), moves with a swap, and is never
|
||||
offered publicly.
|
||||
- **Reports:** roster sheet (phone numbers, check-in time and how), printable; fill rate per event;
|
||||
**hours served** per person (checked-in shifts × shift length) over all events or one.
|
||||
- **Read-only licence:** new roles and sign-ups are blocked; check-ins, swaps and withdrawals keep
|
||||
working.
|
||||
|
||||
---
|
||||
|
||||
@@ -652,8 +729,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):
|
||||
|
||||
@@ -695,6 +776,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
|
||||
@@ -713,6 +811,9 @@ can't start, the same jobs run on in-process timers.
|
||||
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_JOB_ENABLED=false`) |
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
|
||||
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
|
||||
| `webhooks` — send webhook deliveries that are due, retry failures, delete deliveries after 30 days | Every minute (`WEBHOOKS_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
|
||||
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
|
||||
@@ -733,8 +834,14 @@ can't start, the same jobs run on in-process timers.
|
||||
never switches it off: past the end date there's a grace period (banner for site admins only),
|
||||
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
|
||||
created or edited; no new registrations or payments; tickets still scan, data can be exported).
|
||||
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
|
||||
which tells the provider through the Hub (the site never updates itself).
|
||||
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
|
||||
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
|
||||
itself). A request can be **withdrawn**; when a newer version comes out after asking, the page shows
|
||||
the version asked for and **Ask for … instead** moves the request on to the newer one. **Not now** hides the update banner until the next version; urgent updates (security or
|
||||
serious bug fixes) can't be put off and show a red banner.
|
||||
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
|
||||
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
|
||||
Licence. The provider can email them to the church too.
|
||||
See `docs/hub-protocol.md`.
|
||||
- **`/health`** — status, version, database, background jobs and last backup.
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
@@ -748,11 +855,9 @@ can't start, the same jobs run on in-process timers.
|
||||
frontend against a separate, freshly seeded database: public pages, free and paid
|
||||
registration through to check-in and cashup, multi-day check-in, the attendance
|
||||
register and certificates, the event wizard, the kiosk, and the admin jobs page.
|
||||
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
|
||||
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
|
||||
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
|
||||
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
|
||||
goes back to the previous version if a step fails before the reload.
|
||||
- **Deploy script** — `scripts/deploy.sh [tag]`, run on the server, backs up the
|
||||
database, checks out the release, migrates, builds and reloads PM2, and goes back to
|
||||
the previous version if a step fails before the reload.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
@@ -764,6 +869,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
|
||||
| `/series/[slug]` | Public | A series' page: upcoming and past events, Follow |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
@@ -777,9 +883,10 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
|
||||
| `/serve/check-in?code=…` | User (logs in first) | Volunteer check-in (where the event's check-in QR code leads) |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -92,9 +92,9 @@ Documentation: see Frontend README for environment and scripts.
|
||||
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
|
||||
servers and database aren't touched. The database user needs permission to create
|
||||
databases.
|
||||
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
|
||||
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
|
||||
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
|
||||
- There's no CI: run these yourself, plus `npx eslint .` in `frontend/`, before tagging a
|
||||
release (the deploy's `next build` fails on any lint error). Releases are deployed by hand
|
||||
with `scripts/deploy.sh` (see [DEPLOYMENT.md](./DEPLOYMENT.md)).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -116,6 +116,11 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
|
||||
| `WEBHOOKS_JOBS_ENABLED` (env) | `true` | The `webhooks` job (every minute): sends deliveries that are due (retries), picks up sends cut off by a restart, deletes deliveries older than 30 days. New events are sent straight away without it. |
|
||||
| `WEBHOOKS_ENABLED` (env) | `true` | `false` stops noticing events for outbound webhooks altogether (nothing new is queued). |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` (env) | `false` | `true` lets webhook endpoints use http and private or local addresses — for local testing only (the e2e tests set it). |
|
||||
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
|
||||
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
|
||||
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
# What's new in Cross Events
|
||||
|
||||
What each version changes for the church, in plain language. Churches see these notes on Site
|
||||
settings → Licence before they ask for an update, so they can decide whether they want it.
|
||||
|
||||
<!--
|
||||
How this file is used: the Cross Events Hub reads it from main every 15 minutes, and shows a section
|
||||
only once its version is tagged (so write it before tagging; fixes to old sections show straight away). Each version is a "## 1.2.3" heading; add "— urgent" (or "security") to the heading for a
|
||||
security or serious bug fix, which churches can't put off. Under it, lines starting with "- " show as
|
||||
a list and other lines as paragraphs; **bold**, `code` and links are shown as plain text.
|
||||
Write for church admins, not developers: no environment variables, endpoints or job names. The
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
webhooks. If you haven't published your own version, your site shows the new text straight away.
|
||||
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
|
||||
built-in text, section by section, so you can take the new wording with one click and publish.
|
||||
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
|
||||
activate it, and anyone who never has is asked once after they next log in.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
|
||||
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
|
||||
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
|
||||
- Every message is signed so the other system can trust it, failures are tried again
|
||||
automatically, and a delivery log shows what was sent.
|
||||
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
|
||||
|
||||
## 1.47.4
|
||||
|
||||
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
|
||||
asked for, tells you when a newer one is out, and lets you ask for that one instead with one
|
||||
button.
|
||||
|
||||
## 1.47.3
|
||||
|
||||
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
|
||||
year's breakfasts. People buy it on the series page, pay once, and get a ticket for each event.
|
||||
- The money is shared out over the events in line with their normal ticket prices, so each event's
|
||||
cashup shows its part.
|
||||
- Set passes up on the Event series page. A pass stops selling when its first event starts.
|
||||
|
||||
## 1.47.2
|
||||
|
||||
- **Compare a series year on year.** A new report puts the events in a series side by side —
|
||||
registrations, tickets, income and how many came — with how much each went up or down from the
|
||||
one before. Open it from Reports, or with **Compare** on the Event series page.
|
||||
|
||||
## 1.47.1
|
||||
|
||||
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
|
||||
one page that shows what's coming up and what's been. Each event in a series links to the next one.
|
||||
- **Follow a series.** People can follow a series and get an email when the next event in it is
|
||||
out. It counts as news, so only people who chose to get news and announcements receive it.
|
||||
- A copied event stays in its series, and a series can have events at different campuses.
|
||||
|
||||
## 1.47.0
|
||||
|
||||
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
|
||||
and the editor opens with everything filled in: tickets and prices, early birds, sessions, the
|
||||
form, feedback questions, certificates and groups.
|
||||
- **Pick the new start date** and every other date moves with it — sessions, deadlines and
|
||||
early-bird dates included. Check each step, change what's different this time, and save.
|
||||
- Registrations, coupons, attachments, kids check-in and volunteer roles aren't copied.
|
||||
|
||||
## 1.46.2
|
||||
|
||||
- **See what's new before you update.** When a new version is out, the Licence page lists what it
|
||||
brings, so you can decide whether you want it.
|
||||
- **Changed your mind?** Withdraw an update request, and your provider is told not to update the site.
|
||||
- **Not now:** hide the "update available" reminder until the next version comes out. Updates that
|
||||
fix a security problem or a serious bug are marked in red and can't be put off.
|
||||
- **Notices from your provider**, such as planned maintenance, now show as a banner for site admins
|
||||
and supervisors.
|
||||
|
||||
## 1.46.1
|
||||
|
||||
- Behind-the-scenes maintenance. Nothing changes for you.
|
||||
|
||||
## 1.46.0
|
||||
|
||||
- **Volunteer rosters:** set up serving roles (Welcome, Parking, Kids, Sound…) per event or session.
|
||||
Anyone with an account can sign up under My account → Serving, or supervisors can assign people.
|
||||
- Reminders by WhatsApp or email the day before a shift.
|
||||
- **"Can't make it" swaps:** a volunteer asks for cover, and the first to accept takes the place.
|
||||
- Volunteers check in by scanning the event's QR code; kids-room volunteers count as leaders on duty.
|
||||
- Roster sheet with phone numbers and check-in times, and hours served per person.
|
||||
- Fixed: the free kids check-in ticket was offered on the public event page.
|
||||
|
||||
## 1.45.1
|
||||
|
||||
- The built-in privacy notice and terms of use are brought up to date for kids check-in, sessions,
|
||||
certificates and campuses. If you published your own privacy notice, review it under Admin →
|
||||
Privacy.
|
||||
|
||||
## 1.45.0
|
||||
|
||||
- **Kids check-in:** a secure check-in and pickup for children's ministry, run from a staff desk, with
|
||||
no label printers needed.
|
||||
- Parents keep their children's details (allergies, medical and custody notes) and authorised adults
|
||||
under My account → Kids.
|
||||
- Each check-in gives the family a pickup code by WhatsApp or email; children can only be collected
|
||||
with the code.
|
||||
- Rooms with age ranges, capacity and ratios, live room screens, and an offline desk mode.
|
||||
|
||||
## 1.44.0
|
||||
|
||||
- **Staff for some campuses:** supervisors and staff can be limited to certain campuses (Admin →
|
||||
Users). They then see only those campuses' events, plus events for all campuses, everywhere in the
|
||||
dashboard, reports and the scanner.
|
||||
- The scanner says which campus a ticket is for.
|
||||
|
||||
## 1.43.0
|
||||
|
||||
- **Campuses** for churches with more than one campus (Site settings → Campuses, off by default).
|
||||
- Events belong to one campus or to all campuses; the public events page has a campus filter and
|
||||
each campus gets its own page.
|
||||
- Reports, dashboards, broadcasts and the daily summary can be filtered by campus.
|
||||
- People can choose their home campus on their profile.
|
||||
|
||||
## 1.42.1
|
||||
|
||||
- Behind-the-scenes improvements to licence checking. Nothing changes for you.
|
||||
|
||||
## 1.42.0
|
||||
|
||||
- **Request update:** when a new version is out, press Request update on Site settings → Licence and
|
||||
your provider updates the site for you.
|
||||
|
||||
## 1.41.3
|
||||
|
||||
- The platform is now called **Cross Events**. Nothing else changes, and everyone stays signed in.
|
||||
|
||||
## 1.41.2
|
||||
|
||||
- Fixed: the Background jobs page showed last-run times two hours ahead.
|
||||
|
||||
## 1.41.1
|
||||
|
||||
- A site without a licence is now read-only until a licence key is connected.
|
||||
- Fixed: a site whose licence was released kept showing it as active.
|
||||
|
||||
## 1.41.0
|
||||
|
||||
- **Licences:** the site connects to Cross Events with a licence key (Site settings → Licence) and
|
||||
checks in every 15 minutes with its version and health — never anyone's personal information.
|
||||
- After a licence's end date there's a grace period with a reminder for site admins.
|
||||
- Site admins see when a newer version is available.
|
||||
|
||||
## 1.40.0
|
||||
|
||||
- **POPIA toolkit, part 3:** a privacy readiness checklist with a score, and links to fix each item.
|
||||
- **Retention settings:** choose how long personal information is kept. Inactive accounts are warned
|
||||
and then anonymised after 5 years (on by default).
|
||||
- **Breach register:** record security incidents, with draft messages for the people affected and
|
||||
for the Information Regulator.
|
||||
|
||||
## 1.39.0
|
||||
|
||||
- **POPIA toolkit, part 2:** Admin → Privacy (POPIA).
|
||||
- Edit and publish your own privacy notice, with numbered versions, and ask everyone to accept a new
|
||||
version when it changes significantly.
|
||||
- A register of the outside services that handle personal information, a consent log, and an access
|
||||
log of who downloaded personal information.
|
||||
|
||||
## 1.38.0
|
||||
|
||||
- **POPIA toolkit, part 1:** a Privacy page for everyone (My account → Privacy): choose whether to get
|
||||
news by email or WhatsApp, download your data, ask for a correction or delete your account.
|
||||
- Bulk messages are either **event updates** (to everyone booked) or **news** (only to people who
|
||||
opted in, with an unsubscribe link). Replying STOP on WhatsApp stops news.
|
||||
- Admins handle correction and deletion requests under Admin → Data requests.
|
||||
|
||||
## 1.37.4
|
||||
|
||||
- Behind-the-scenes improvements to updating the site. Nothing changes for you.
|
||||
|
||||
## 1.37.3
|
||||
|
||||
- Behind-the-scenes fix. Nothing changes for you.
|
||||
|
||||
## 1.37.2
|
||||
|
||||
- **Payment status checks:** online payments whose confirmation never arrived from PayFast are found
|
||||
and recorded automatically, with a "Check status" button under Payments → Reconcile.
|
||||
- A "Test connection" button for the PayFast settings.
|
||||
|
||||
## 1.37.1
|
||||
|
||||
- "Pay online" is no longer offered when online payments are off or the amount is under R15; people
|
||||
are told to pay at the event instead.
|
||||
|
||||
## 1.37.0
|
||||
|
||||
- **PayFast:** a second online payment provider (card, instant EFT and more), set up in Site
|
||||
settings → Payments.
|
||||
- When more than one provider is on, people choose how to pay.
|
||||
|
||||
## 1.36.0
|
||||
|
||||
- **Site settings → Payments:** turn online payment providers on or off and choose their order.
|
||||
- Online payments must be at least R15; smaller balances are paid at the door.
|
||||
|
||||
## 1.35.1
|
||||
|
||||
- **Waitlist per session** on events where people pick sessions.
|
||||
- "All sessions" tickets let people choose which sessions they'll come to.
|
||||
- At the door, walk-ins can be booked straight into today's session.
|
||||
- Several fixes for per-session tickets and stock limits.
|
||||
|
||||
## 1.35.0
|
||||
|
||||
- **Pick sessions:** a multi-session event can let people choose which sessions to come to — day
|
||||
passes for a conference, or a weekly drop-in class — with a price per session.
|
||||
- Each session can have its own capacity, and tickets only scan on their own session.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
- **Background jobs page** (Site settings → Background jobs): see every scheduled task, when it last
|
||||
ran, and run it now.
|
||||
- Scheduled tasks (reminders, summaries, backups) are more reliable and run on South African time.
|
||||
- Fixed: at the door, multi-day tickets showed as used up from the second session.
|
||||
|
||||
## 1.33.0
|
||||
|
||||
- **Multi-day events:** an event can run over several sessions (e.g. every Wednesday for 5 weeks);
|
||||
people register and pay once.
|
||||
- Check-in per session, optional reminders before each session, and late joining at a lower price.
|
||||
- **Attendance register** with ticks per session and Excel/PDF export.
|
||||
- **Completion certificates** with four designs, issued automatically, each with a QR code anyone can
|
||||
check.
|
||||
|
||||
## 1.32.0
|
||||
|
||||
- Event cards show an "Early bird" ribbon while early-bird pricing is running.
|
||||
|
||||
## 1.31.1
|
||||
|
||||
- A field for Google Search Console verification (Site settings → Organisation).
|
||||
|
||||
## 1.31.0
|
||||
|
||||
- The registration funnel recognises visits from Facebook, Instagram, Google and other sites without
|
||||
tagged links, and shows shares and QR code scans separately.
|
||||
- Events can appear in Google's event listings with their date, venue and tickets.
|
||||
|
||||
## 1.30.1
|
||||
|
||||
- All tickets — emailed, WhatsApped and printed — share one clean design, with bigger QR codes.
|
||||
|
||||
## 1.30.0
|
||||
|
||||
- People can add an optional **donation** when they register, paid together with their tickets.
|
||||
- Fixed: event pages with a waitlist could crash.
|
||||
|
||||
## 1.29.1
|
||||
|
||||
- The Coupon codes page is easier to use, including on phones, and hides codes that are off, expired
|
||||
or used up.
|
||||
|
||||
## 1.29.0
|
||||
|
||||
- Bundle deals come in four types: an exact set, pick any mix, buy X get Y free, and group discounts.
|
||||
|
||||
## 1.28.3
|
||||
|
||||
- Fixed: the bundle deals form didn't fit on the screen.
|
||||
|
||||
## 1.28.2
|
||||
|
||||
- The Bundle deals page has a new layout that works on phones.
|
||||
|
||||
## 1.28.1
|
||||
|
||||
- Bundle deals work at the door, in manual registration and on the self-service kiosk too.
|
||||
|
||||
## 1.28.0
|
||||
|
||||
- **Bundle deals:** sell a set of tickets together for one price, e.g. 2 adults + 3 kids for R220.
|
||||
They apply automatically, with no code, and the saving shows on invoices and receipts.
|
||||
|
||||
## 1.27.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.27.0
|
||||
|
||||
- **Cashups** record where each payment method's money ended up: in the bank, cash on hand, or still
|
||||
pending. Card fees and bank charges can be written off with a reason.
|
||||
- Pending money can be marked received later without reopening the event.
|
||||
- Every dashboard page has the same "Back to dashboard" link.
|
||||
|
||||
## 1.26.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.26.0
|
||||
|
||||
- Behind-the-scenes upgrades and security updates.
|
||||
- Fixed: the refund reason didn't show at the door.
|
||||
|
||||
## 1.25.1
|
||||
|
||||
- On phones, the cookie notice and the help button no longer hide each other or the menu.
|
||||
|
||||
## 1.25.0
|
||||
|
||||
- **Yoco settings** in Site settings: enter and check the secret key, and set up payment
|
||||
notifications with one click.
|
||||
|
||||
## 1.24.0
|
||||
|
||||
- **Ticket transfers** now need the new holder to accept, by a link sent to them.
|
||||
- Updated terms of use and privacy policy, with a full cookie policy and a "Cookie settings" link.
|
||||
- Fixed: activating an account didn't sign you in.
|
||||
|
||||
## 1.23.0
|
||||
|
||||
- **View as user:** admins can see the site exactly as someone else does, to help with problems.
|
||||
It's read-only, limited to 30 minutes and recorded in the audit log.
|
||||
|
||||
## 1.22.0
|
||||
|
||||
- **Registration funnel report:** see where people drop off between viewing an event and paying, by
|
||||
traffic source and device. Only visitors who allow it are counted.
|
||||
|
||||
## 1.21.0
|
||||
|
||||
- **Post-event feedback:** send a short survey after an event, with one reminder, and see the results
|
||||
in a Feedback summary report.
|
||||
- New form question types: star ratings and multiple choice.
|
||||
|
||||
## 1.20.0
|
||||
|
||||
- **Sponsor a spot:** people can pay for a place for someone who can't afford it, with an optional
|
||||
goal and progress bar.
|
||||
|
||||
## 1.19.0
|
||||
|
||||
- **Rooms & groups** for camps: put attendees into rooms, cabins, small groups or buses by drag and
|
||||
drop or automatically, with printable lists.
|
||||
- Events can ask each person's gender.
|
||||
|
||||
## 1.18.0
|
||||
|
||||
- **Ticket transfer:** hand a ticket to someone else before the event; the old QR code stops working.
|
||||
|
||||
## 1.17.0
|
||||
|
||||
- **Offline ticket scanning:** download an event's tickets to a device and keep scanning without a
|
||||
connection; scans sync when it's back online.
|
||||
|
||||
## 1.16.0
|
||||
|
||||
- **Waitlists** for sold-out tickets: when a place frees up, the next person is offered it
|
||||
automatically.
|
||||
|
||||
## 1.15.0
|
||||
|
||||
- **Parental consent** for youth events: parents give consent online, with optional medical and
|
||||
emergency details, before their child can be checked in.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
- **Named attendees:** when booking for several people, each person can be named and get their own
|
||||
ticket.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
- **Coupon codes:** percentage, fixed-amount or free codes, with limits on events, dates and uses.
|
||||
- Staff prices for tickets.
|
||||
- A Discounts report.
|
||||
|
||||
## 1.12.0
|
||||
|
||||
- Two-factor login is now required for admins and supervisors.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- **Two-factor login** for everyone who wants it: a code by email or WhatsApp after the password, and
|
||||
"Trust this device for 30 days".
|
||||
|
||||
## 1.10.6
|
||||
|
||||
- Security updates.
|
||||
|
||||
## 1.10.5
|
||||
|
||||
- Events that have started but not finished stay listed on the home and events pages.
|
||||
|
||||
## 1.10.4
|
||||
|
||||
- Fixed: "Add to calendar" didn't work.
|
||||
|
||||
## 1.10.3
|
||||
|
||||
- More admin actions are written to the audit log.
|
||||
|
||||
## 1.10.2
|
||||
|
||||
- Behind-the-scenes fix to error monitoring. Nothing changes for you.
|
||||
|
||||
## 1.10.1
|
||||
|
||||
- Security fixes for file uploads, and security updates.
|
||||
|
||||
## 1.10.0
|
||||
|
||||
- **Add to calendar** on event pages and in confirmation emails.
|
||||
- Better link previews and search engine listings for events.
|
||||
- **Nightly database backups**, which you can download from Site settings → Backups.
|
||||
- **Audit log** of refunds, donations, manual registrations, cancellations, event changes and
|
||||
settings changes.
|
||||
|
||||
## 1.9.5
|
||||
|
||||
- The site copes better with automated traffic probing for pages that don't exist.
|
||||
|
||||
## 1.9.4
|
||||
|
||||
- Behind-the-scenes server setting. Nothing changes for you.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
- People registered at the door or by staff get their account activation link straight away.
|
||||
- Fixed: the self-service kiosk's "Choose a password" didn't work.
|
||||
|
||||
## 1.9.2
|
||||
|
||||
- Contact-only events no longer get a daily summary email.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
- Fixed: the browser tab and home page showed the default name instead of your church's name.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
- Events have a **location**, with a "Directions" link and a map on the event and contact pages.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- **Contact-only events** (e.g. baptism) show a "Contact us" button instead of registration.
|
||||
- **Invoices and receipts** as branded PDFs with registration and payment emails, with a "pay now"
|
||||
link and QR code.
|
||||
- Fixed: buying more tickets after early bird ended re-priced the tickets already bought.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- **Brand colours:** primary, secondary and accent colours across the whole site and emails, a
|
||||
favicon, and a live preview. Colours can be suggested from your logo.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- Supervisors can unassign a donation that was applied to a registration.
|
||||
- Faster payments and dashboards.
|
||||
|
||||
## 1.5.4
|
||||
|
||||
- The home page shows "My Dashboard" to people who are signed in.
|
||||
|
||||
## 1.5.3
|
||||
|
||||
- Fixed: help popups could be taller than a phone screen.
|
||||
|
||||
## 1.5.2
|
||||
|
||||
- Dashboard tiles and charts fit better on phones.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- Fixed: scheduled WhatsApp messages were sent as emails.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- **Site redesign:** a new look across the whole site, new dashboards with trends and top events, and
|
||||
a "Need help?" button with a guide on every page.
|
||||
- A separate Contact page, and recent sign-ins on Profile & Security.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
- Fixes to how refunds and donations are counted in reports and cashups.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
- Reports fit better on phones.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
- **Reports redesigned:** filters that apply to every report, charts, and styled PDF and Excel
|
||||
exports, by email or WhatsApp too.
|
||||
- Cashups show who recorded each payment, with per-person cash counts.
|
||||
- A payment link placeholder for messages to attendees.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
- The self-service kiosk is safer with returning visitors' details, and asks before changing them.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- The self-service kiosk can find a returning visitor's account.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
- **Check-in at the door** by number of people, without scanning each QR code, and a confirmation
|
||||
message to the ticket holder.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- "Make payment" goes straight to paying the full balance.
|
||||
- Staff can record a payment straight after a manual registration.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- A payment history page in My account.
|
||||
- Registration status shows as a coloured badge.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Closed and past events are tidied away in My Events.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- The first release.
|
||||
@@ -75,6 +75,13 @@ WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
|
||||
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
|
||||
|
||||
@@ -136,6 +136,7 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
|
||||
| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias |
|
||||
| POST | `/api/events` | supervisor+ | Create event |
|
||||
| PUT | `/api/events/:id` | supervisor+ | Update event |
|
||||
| POST | `/api/events/:id/copy-from` | supervisor+ | Copying an event: copy `fromEventId`'s allocation sets/groups (not kids rooms) and, for admins, planned costs into this new event (409 once it has registrations or groups) |
|
||||
| DELETE | `/api/events/:id` | admin | Delete event |
|
||||
| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) |
|
||||
| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
|
||||
@@ -170,6 +171,10 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
|
||||
}
|
||||
```
|
||||
|
||||
A copy made with "Copy" on the Events page is an ordinary `POST /api/events` from the event form,
|
||||
with `clonedFromId` (the source event, noted in the `event_created` audit entry), followed by the
|
||||
options, feedback form, sections and recipients as for a new event, then `POST /api/events/:id/copy-from`.
|
||||
|
||||
---
|
||||
|
||||
## Event Options & Variants
|
||||
@@ -844,10 +849,13 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable, urgentUpdate, updateSnoozed, releases: [{ version, releasedAt, urgent, notes }], updateRequest, notices }` — `releases` is what's new in each version newer than this one (from the Hub) |
|
||||
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
|
||||
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
|
||||
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
|
||||
| POST | `/api/hub/update-request/cancel` | admin | Withdraw the update request; the Hub tells the provider. Returns the status. Audited as `hub_update_request_withdrawn` |
|
||||
| POST | `/api/hub/update-snooze` | admin | `{ snooze }` — "Not now": `updateSnoozed` is true (no banner) until a newer version than the one put off is out. `400` when there's no update or it's urgent (security/serious fix). `{ snooze: false }` turns reminders back on. Audited as `hub_update_snoozed` / `hub_update_unsnoozed` |
|
||||
| GET | `/api/hub/notices` | supervisor | The provider's notices showing now: `[{ id, level: info\|maintenance\|warning, title, message, startsAt, endsAt, updatedAt }]`, from the last heartbeat |
|
||||
|
||||
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
|
||||
|
||||
@@ -922,6 +930,102 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
|
||||
| GET | `/api/kids/events/:eventId/offline?deviceId=&sessionId=` | staff+ | Offline snapshot: board plus `families` and `codes` (20 reserved for the device). Access log `kids_offline` |
|
||||
| POST | `/api/kids/events/:eventId/sync` | staff+ | `{ deviceId, occasion, checkIns: [{ clientId, familyId, childId, roomId, code, at }], checkOuts: [{ clientId, collectedBy, at }] }` → `{ checkedIn, checkedOut, conflicts }`; idempotent by `clientId`; audited as `kids_offline_sync` |
|
||||
|
||||
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`.
|
||||
|
||||
## Event series
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/series` | supervisor+ | Every series with `followers`, `eventCount` and its events (campus-scoped: their campuses' events only) |
|
||||
| POST | `/api/series` | supervisor+ | Create: `{ name, slug?, description?, picture? }` (slug made from the name). 409 when the slug is taken |
|
||||
| PUT | `/api/series/:id` | supervisor+ | Change any of those fields |
|
||||
| DELETE | `/api/series/:id` | admin | Delete; events stay without a series, followers dropped |
|
||||
| GET | `/api/series/by-slug/:slug` | public | `{ id, name, slug, description, picture, upcoming, past, following }` — public events only |
|
||||
| POST / DELETE | `/api/series/:id/follow` | user | Follow / unfollow |
|
||||
| GET | `/api/series/:id/compare` | supervisor+ | Year on year: `{ series, events: [{ eventId, title, startDate, registrations, tickets, income, checkedIn, attendanceRate, sessions, change }] }`, oldest first; `change` is % against the event before. Scoped staff: their campuses' events only |
|
||||
|
||||
Events take `seriesId` (null = none) on create and update; `GET /api/events?series=<slug>` lists one
|
||||
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
|
||||
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
Admin only. Endpoints receive `POST` JSON `{ id: "evt_…", type, createdAt, data }` with headers
|
||||
`X-CrossEvents-Event`, `X-CrossEvents-Delivery` and `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex>`
|
||||
where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anything else is retried
|
||||
(1 min, 5 min, 30 min, 2 h, 12 h). The same `id` can arrive more than once — de-duplicate on it.
|
||||
|
||||
| Type | `data` |
|
||||
|------|--------|
|
||||
| `registration.created` / `.paid` / `.cancelled` | `registration { id, status, totalDue, amountPaid, items[], seasonPassPurchaseId }`, `event`, `person` |
|
||||
| `payment.received` / `payment.refunded` | `payment { id, amount, method, provider, isDonation, registrationId }`, `event`, `person` |
|
||||
| `ticket.checked_in` | `checkIn { id, scannedAt, people, manual, offline, session }`, `ticket`, `event`, `person` |
|
||||
| `user.created` | `person { id, name, email, phone, role, createdAt }` |
|
||||
| `event.published` | `event { id, title, description, startDate, endDate, location, price, goLiveAt, url, campus, series }` |
|
||||
| `webhook.test` | `{ message }` (Send test) |
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
|
||||
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
|
||||
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
|
||||
|
||||
## Season passes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/season-passes?seriesId=` | supervisor+ | Passes with events, normal prices, `share` per event, `sold`, `unpaid` |
|
||||
| POST | `/api/season-passes` | supervisor+ | `{ seriesId, name, description?, price, saleEndsAt?, isActive?, items: [{ eventId, eventOptionId, variantId? }] }` |
|
||||
| PUT | `/api/season-passes/:id` | supervisor+ | Change it; events and price only while unsold (409) |
|
||||
| DELETE | `/api/season-passes/:id` | supervisor+ | Only while unsold |
|
||||
| POST | `/api/season-passes/:id/buy` | user | `{ quantity }` → `{ purchaseId, total, paid, redirectUrl, message }`; 409 when not on sale or too few places |
|
||||
| POST | `/api/season-passes/purchases/:id/checkout` | user (buyer) | A new checkout for what is still owed |
|
||||
| GET | `/api/season-passes/mine` | user | My purchases with their registrations |
|
||||
|
||||
A purchase creates one `Registration` per event (`seasonPassPurchaseId`), its line priced at the
|
||||
event's share. The checkout's metadata is `{ type: 'season_pass', purchaseId }`; the webhook records
|
||||
one `Payment` per registration (`seasonPassPurchaseId`; only the first carries `externalId`).
|
||||
`PUT /api/registrations/:id/options` and `POST /api/registrations/:id/coupon` answer 409 for pass
|
||||
registrations.
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
|
||||
one session of a multi-session event (`sessionId` required there), with `needed` people and optional
|
||||
`startsAt`/`endsAt` (default: the session's or event's times). A **slot** is a person on a role;
|
||||
nobody can hold two slots whose shifts overlap (`409 SHIFT_CLASH`). When `Event.volunteerOptionId`
|
||||
is set, each volunteer without a ticket gets a free place on it (cancelled when they leave, moved
|
||||
with a swap). `volunteerOptionId` and `kidsOptionId` are left out of public event pages
|
||||
(`GET /api/events`, `GET /api/events/:id` for non-staff) and refused on public registration. Staff
|
||||
routes are campus-scoped.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/volunteers/me` | user | `{ slots, cover, events }` — my shifts (upcoming and the last week; `canCheckIn`, `swapId`, `hasTicket`), open cover requests I could take, and public events with roles |
|
||||
| GET | `/api/volunteers/events/:eventId` | user | `{ event, sessions, roles, totals }` — each role with `shift`, `needed`, `filled`, `full`, `mine`, `coverNeeded` (no names) |
|
||||
| POST | `/api/volunteers/roles/:id/sign-up` | user | `201 { slotId, ticket }`; `409 ROLE_FULL`, `409 SHIFT_CLASH`. Blocked in licence read-only mode |
|
||||
| DELETE | `/api/volunteers/slots/:id` | user | Withdraw (not after check-in or once the shift is over); the event's notify recipients are emailed |
|
||||
| POST | `/api/volunteers/slots/:id/swap` | user | `{ note? }` → `{ swapId }` — "can't make it"; the event's other volunteers are told |
|
||||
| DELETE | `/api/volunteers/swaps/:id` | user | Cancel my cover request |
|
||||
| POST | `/api/volunteers/swaps/:id/accept` | user | Take the place over at once → `{ slotId, role, event }`; `409 SWAP_CLOSED` if someone was first. Both volunteers and the event's notify recipients are told |
|
||||
| POST | `/api/volunteers/check-in` | user | `{ qr }` (`SERVE-<token>`) → `{ event, checkedIn: [{ role, kidsRoom }], already }` for my shifts on today. `404 BAD_QR`, `404 NOT_ROSTERED`, `400 NOT_TODAY`. A kids-room role starts a `KidsRoomDuty` (`fromRoster`) |
|
||||
| GET | `/api/volunteers/manage/events?all=1` | staff+ | Events with roles from the last 30 days on; `all=1` (supervisors) every upcoming event |
|
||||
| GET | `/api/volunteers/manage/events/:eventId?log=1` | staff+ | The roster with `volunteers: [{ slotId, name, phone, email, checkedInAt, checkInMethod, swapOpen, hasTicket }]` per role, and `totals: { needed, filled, checkedIn, fillRate }`. `log=1` writes the access log (`volunteer_roster`) |
|
||||
| GET | `/api/volunteers/manage/events/:eventId/qr` | staff+ | `{ token, qr }` — the check-in QR value (the page encodes `/serve/check-in?code=<qr>`) |
|
||||
| POST | `/api/volunteers/manage/slots/:id/check-in` | staff+ | `{ undo? }` — check in by hand (or undo, which ends rostered kids-room duty). Audited as `volunteer_checked_in_manual` |
|
||||
| GET / PUT | `/api/volunteers/manage/events/:eventId/settings` | supervisor+ | `{ optionId }` — the volunteer ticket: a main ticket covering the whole event, `'new'` for a free "Volunteer" ticket, `null` = off (default). Also returns the event's `kidsRooms`. Audited as `volunteer_settings_updated` |
|
||||
| POST | `/api/volunteers/manage/events/:eventId/roles` | supervisor+ | `{ name, needed, sessionId?, startsAt?, endsAt?, description?, kidsRoomId? }`. Audited as `volunteer_roles_changed`; blocked in read-only mode |
|
||||
| PUT / DELETE | `/api/volunteers/manage/roles/:id` | supervisor+ | Change / delete a role (delete refused once someone checked in; its volunteers are told) |
|
||||
| POST | `/api/volunteers/manage/roles/:id/assign` | supervisor+ | `{ userId }` — allowed beyond `needed`; the person is told. Audited as `volunteer_assigned` |
|
||||
| DELETE | `/api/volunteers/manage/slots/:id` | supervisor+ | Take someone off (not after check-in); they're told. Audited as `volunteer_removed` |
|
||||
| GET | `/api/volunteers/manage/hours?eventId=&from=&to=` | supervisor+ | `[{ userId, name, shifts, hours, events }]` — checked-in shifts × shift length, most first |
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VolunteerSwapStatus" AS ENUM ('open', 'accepted', 'cancelled');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_settings_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_roles_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_assigned';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_removed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_checked_in_manual';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "volunteerCheckInToken" TEXT,
|
||||
ADD COLUMN "volunteerOptionId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "KidsRoomDuty" ADD COLUMN "fromRoster" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServingRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"needed" INTEGER NOT NULL DEFAULT 1,
|
||||
"startsAt" TIMESTAMP(3),
|
||||
"endsAt" TIMESTAMP(3),
|
||||
"kidsRoomId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ServingRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSlot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"assignedById" TEXT,
|
||||
"checkedInAt" TIMESTAMP(3),
|
||||
"checkedInById" TEXT,
|
||||
"checkInMethod" TEXT,
|
||||
"remindedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VolunteerSlot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSwap" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slotId" TEXT,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"status" "VolunteerSwapStatus" NOT NULL DEFAULT 'open',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "VolunteerSwap_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_eventId_sessionId_idx" ON "ServingRole"("eventId", "sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_kidsRoomId_idx" ON "ServingRole"("kidsRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_userId_idx" ON "VolunteerSlot"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_registrationId_idx" ON "VolunteerSlot"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VolunteerSlot_roleId_userId_key" ON "VolunteerSlot"("roleId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_roleId_status_idx" ON "VolunteerSwap"("roleId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_slotId_idx" ON "VolunteerSwap"("slotId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_volunteerCheckInToken_key" ON "Event"("volunteerCheckInToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_kidsRoomId_fkey" FOREIGN KEY ("kidsRoomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_assignedById_fkey" FOREIGN KEY ("assignedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_slotId_fkey" FOREIGN KEY ("slotId") REFERENCES "VolunteerSlot"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_request_withdrawn';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_snoozed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_unsnoozed';
|
||||
@@ -0,0 +1,59 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "seriesAnnouncedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "seriesId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventSeries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventSeries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeriesFollow" (
|
||||
"id" TEXT NOT NULL,
|
||||
"seriesId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SeriesFollow_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventSeries_slug_key" ON "EventSeries"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeriesFollow_userId_idx" ON "SeriesFollow"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SeriesFollow_seriesId_userId_key" ON "SeriesFollow"("seriesId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_seriesId_idx" ON "Event"("seriesId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'season_pass_changed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "seasonPassPurchaseId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "seasonPassPurchaseId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPass" (
|
||||
"id" TEXT NOT NULL,
|
||||
"seriesId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"saleEndsAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeasonPass_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPassItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "SeasonPassItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeasonPassPurchase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"total" DOUBLE PRECISION NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"checkoutId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeasonPassPurchase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPass_seriesId_idx" ON "SeasonPass"("seriesId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_eventId_idx" ON "SeasonPassItem"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_eventOptionId_idx" ON "SeasonPassItem"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassItem_variantId_idx" ON "SeasonPassItem"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SeasonPassItem_passId_eventId_key" ON "SeasonPassItem"("passId", "eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_passId_idx" ON "SeasonPassPurchase"("passId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_userId_idx" ON "SeasonPassPurchase"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeasonPassPurchase_checkoutId_idx" ON "SeasonPassPurchase"("checkoutId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPass" ADD CONSTRAINT "SeasonPass_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'webhook_endpoint_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookEndpoint" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"events" TEXT[],
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"operatorId" TEXT,
|
||||
"lastSuccessAt" TIMESTAMP(3),
|
||||
"lastFailureAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"endpointId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastStatusCode" INTEGER,
|
||||
"lastError" TEXT,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_userId_idx" ON "WebhookDelivery"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -158,6 +158,15 @@ model User {
|
||||
kidsAlertsRaised KidsAlert[]
|
||||
kidsRoomDuties KidsRoomDuty[]
|
||||
|
||||
// Volunteer rosters (1.46.0)
|
||||
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
|
||||
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@index([homeCampusId])
|
||||
}
|
||||
|
||||
@@ -304,10 +313,117 @@ model Event {
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
|
||||
// Volunteer rosters (1.46.0): serving roles for the event or one of its sessions. When
|
||||
// `volunteerOptionId` is set (a main ticket of this event; off by default), each volunteer
|
||||
// gets a free place on it. `volunteerCheckInToken` is in the check-in QR code volunteers scan
|
||||
// on the day (made the first time the QR code is shown). Plain id, checked in utils/volunteers.js.
|
||||
servingRoles ServingRole[]
|
||||
volunteerOptionId String?
|
||||
volunteerCheckInToken String? @unique
|
||||
|
||||
// Event series (1.47.1): separate events grouped on one series page (Camp 2025, Camp 2026).
|
||||
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
|
||||
seriesId String?
|
||||
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
||||
seasonPassItems SeasonPassItem[]
|
||||
seriesAnnouncedAt DateTime?
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
@@index([campusId])
|
||||
@@index([seriesId])
|
||||
}
|
||||
|
||||
/// Separate events grouped together, e.g. every year's camp or the monthly men's breakfast.
|
||||
/// Not tied to a campus: a series can have events at several campuses.
|
||||
model EventSeries {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
picture String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events Event[]
|
||||
followers SeriesFollow[]
|
||||
passes SeasonPass[]
|
||||
}
|
||||
|
||||
/// Someone who wants to hear when a new event in the series is out (sent as news).
|
||||
model SeriesFollow {
|
||||
id String @id @default(uuid())
|
||||
seriesId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([seriesId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// A season pass (1.47.3): one price for a ticket at each of several events in a series. Buying
|
||||
/// it registers the buyer for every event on it in one checkout; the price is split across the
|
||||
/// events in proportion to their normal ticket prices (for each event's cashup).
|
||||
model SeasonPass {
|
||||
id String @id @default(uuid())
|
||||
seriesId String
|
||||
name String
|
||||
description String?
|
||||
price Float
|
||||
isActive Boolean @default(true)
|
||||
// Stops selling then (it always stops when the first event on it starts).
|
||||
saleEndsAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
|
||||
items SeasonPassItem[]
|
||||
purchases SeasonPassPurchase[]
|
||||
|
||||
@@index([seriesId])
|
||||
}
|
||||
|
||||
/// One event on a pass: which ticket (and variant) the pass gives there.
|
||||
model SeasonPassItem {
|
||||
id String @id @default(uuid())
|
||||
passId String
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
order Int @default(0)
|
||||
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([passId, eventId])
|
||||
@@index([eventId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
/// Someone's purchase of a pass: `quantity` passes, one registration per event.
|
||||
model SeasonPassPurchase {
|
||||
id String @id @default(uuid())
|
||||
passId String
|
||||
userId String
|
||||
quantity Int
|
||||
total Float
|
||||
// pending | paid | cancelled
|
||||
status String @default("pending")
|
||||
// The online checkout started for it (our ProviderCheckout id).
|
||||
checkoutId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Restrict)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
|
||||
@@index([passId])
|
||||
@@index([userId])
|
||||
@@index([checkoutId])
|
||||
}
|
||||
|
||||
model EventOption {
|
||||
@@ -331,6 +447,7 @@ model EventOption {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -367,6 +484,7 @@ model OptionVariant {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -397,8 +515,13 @@ model Registration {
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
// Season pass (1.47.3): one registration per event on the pass, its line priced at that
|
||||
// event's share of the pass price (never repriced, no coupons, options not editable).
|
||||
seasonPassPurchaseId String?
|
||||
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
volunteerSlots VolunteerSlot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -486,6 +609,10 @@ model Payment {
|
||||
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
||||
splitPayments Payment[] @relation("SplitPayments")
|
||||
providerTransactions ProviderTransaction[]
|
||||
// Season pass: one provider payment is recorded as one Payment per event on the pass (its
|
||||
// share), all linked to the purchase. Only the first carries the provider's externalId.
|
||||
seasonPassPurchaseId String?
|
||||
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([recordedById])
|
||||
@@ -666,6 +793,9 @@ enum AdminAuditAction {
|
||||
breach_updated
|
||||
hub_connected
|
||||
hub_update_requested
|
||||
hub_update_request_withdrawn
|
||||
hub_update_snoozed
|
||||
hub_update_unsnoozed
|
||||
campuses_toggled
|
||||
campus_created
|
||||
campus_updated
|
||||
@@ -678,6 +808,16 @@ enum AdminAuditAction {
|
||||
kids_wrong_code
|
||||
kids_custody_notes_changed
|
||||
kids_offline_sync
|
||||
volunteer_settings_updated
|
||||
volunteer_roles_changed
|
||||
volunteer_assigned
|
||||
volunteer_removed
|
||||
volunteer_checked_in_manual
|
||||
series_created
|
||||
series_updated
|
||||
series_deleted
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1364,6 +1504,7 @@ model AllocationGroup {
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
kidsDuties KidsRoomDuty[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
@@ -1639,6 +1780,7 @@ model EventSession {
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
@@ -2017,9 +2159,140 @@ model KidsRoomDuty {
|
||||
userId String
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
// Volunteer rosters (1.46.0): started by a rostered volunteer checking in, not "I'm on duty".
|
||||
fromRoster Boolean @default(false)
|
||||
room AllocationGroup @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId, occasion])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters (1.46.0) ───────────────────────────────────────────────
|
||||
|
||||
// A serving role (Welcome, Parking, Sound…) for one event — or, on a multi-session event, one
|
||||
// session — with the number of people needed. Shift times are optional (null = the session's or
|
||||
// event's times). A role linked to a kids room puts its volunteers on duty there when they check
|
||||
// in, so they count for the room's ratio.
|
||||
model ServingRole {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sessionId String?
|
||||
name String
|
||||
description String?
|
||||
needed Int @default(1)
|
||||
startsAt DateTime?
|
||||
endsAt DateTime?
|
||||
kidsRoomId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
kidsRoom AllocationGroup? @relation(fields: [kidsRoomId], references: [id], onDelete: SetNull)
|
||||
slots VolunteerSlot[]
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@index([eventId, sessionId])
|
||||
@@index([kidsRoomId])
|
||||
}
|
||||
|
||||
// A person serving in a role. `assignedById` is the supervisor who put them there (null = they
|
||||
// signed up). Withdrawing or being removed deletes the row; a swap moves it to the person who
|
||||
// takes it over. `registrationId` is the free volunteer place they were given, if any.
|
||||
model VolunteerSlot {
|
||||
id String @id @default(uuid())
|
||||
roleId String
|
||||
userId String
|
||||
assignedById String?
|
||||
checkedInAt DateTime?
|
||||
checkedInById String?
|
||||
// 'qr' (the volunteer scanned the check-in QR code) or 'manual' (ticked by staff)
|
||||
checkInMethod String?
|
||||
remindedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
user User @relation("VolunteerSlotUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignedBy User? @relation("VolunteerSlotAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull)
|
||||
checkedInBy User? @relation("VolunteerSlotCheckedInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@unique([roleId, userId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
enum VolunteerSwapStatus {
|
||||
open
|
||||
accepted
|
||||
cancelled
|
||||
}
|
||||
|
||||
// "Can't make it": the volunteer asks someone to take their place. It happens as soon as someone
|
||||
// accepts (the slot moves to them), and the event's notify recipients are told.
|
||||
model VolunteerSwap {
|
||||
id String @id @default(uuid())
|
||||
slotId String?
|
||||
roleId String
|
||||
fromUserId String?
|
||||
toUserId String?
|
||||
note String?
|
||||
status VolunteerSwapStatus @default(open)
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
slot VolunteerSlot? @relation(fields: [slotId], references: [id], onDelete: SetNull)
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
fromUser User? @relation("VolunteerSwapFrom", fields: [fromUserId], references: [id], onDelete: SetNull)
|
||||
toUser User? @relation("VolunteerSwapTo", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([roleId, status])
|
||||
@@index([slotId])
|
||||
}
|
||||
|
||||
/// Outbound webhooks (1.48.0): a URL that is sent what happens here (registrations, payments,
|
||||
/// check-ins, new accounts, published events), signed with its own secret.
|
||||
model WebhookEndpoint {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
url String
|
||||
// HMAC secret, encrypted (utils/encryption.js); shown to the admin once, when made or rotated.
|
||||
secret String
|
||||
// Event types it gets, e.g. ["registration.paid", "ticket.checked_in"].
|
||||
events String[]
|
||||
isActive Boolean @default(true)
|
||||
// The operator register entry made for it (POPIA), if any.
|
||||
operatorId String?
|
||||
lastSuccessAt DateTime?
|
||||
lastFailureAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deliveries WebhookDelivery[]
|
||||
}
|
||||
|
||||
/// One message to one endpoint, with its retries. Kept 30 days.
|
||||
model WebhookDelivery {
|
||||
id String @id @default(uuid())
|
||||
endpointId String
|
||||
// The event's own id (the same for every endpoint it went to) and type.
|
||||
eventId String
|
||||
type String
|
||||
payload Json
|
||||
// pending | sending | retrying | succeeded | failed
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastStatusCode Int?
|
||||
lastError String?
|
||||
deliveredAt DateTime?
|
||||
// Whose personal information is in it, so it can be removed with their account.
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([endpointId, createdAt])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
function createBaseClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
@@ -11,6 +11,86 @@ function createPrismaClient() {
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound webhooks (utils/webhooks.js) notice what happens here, whichever code path does it:
|
||||
* registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, events
|
||||
* becoming public. Noticing only queues; the record is read again before anything is sent, so a
|
||||
* transaction that rolls back sends nothing. A failure here never affects the write itself.
|
||||
*/
|
||||
function withWebhooks(base) {
|
||||
const notice = (type, id) => {
|
||||
try { require('../utils/webhooks').notice(type, id); } catch (e) { console.warn('[webhooks] notice:', e?.message || e); }
|
||||
};
|
||||
const isPublic = e => !!e && e.isActive !== false && !e.isHidden;
|
||||
// The status before an update (read outside the caller's transaction: the committed value).
|
||||
const before = (model, where, select) => (where?.id ? base[model].findUnique({ where: { id: where.id }, select }).catch(() => null) : Promise.resolve(null));
|
||||
|
||||
return base.$extends({
|
||||
query: {
|
||||
registration: {
|
||||
async create({ args, query }) {
|
||||
const r = await query(args);
|
||||
if (r?.id) {
|
||||
notice('registration.created', r.id);
|
||||
if (r.status === 'paid') notice('registration.paid', r.id);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const status = args?.data?.status;
|
||||
if (status !== 'paid' && status !== 'cancelled') return query(args);
|
||||
const prev = await before('registration', args.where, { status: true });
|
||||
const r = await query(args);
|
||||
if (r?.id && prev && prev.status !== status) notice(status === 'paid' ? 'registration.paid' : 'registration.cancelled', r.id);
|
||||
return r;
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
async create({ args, query }) {
|
||||
const p = await query(args);
|
||||
// Applying an earlier donation to an order isn't new money (see cashupUtils.isDonationLeg).
|
||||
const leg = p && !p.isDonation && p.originalPaymentId && p.amount > 0;
|
||||
if (p?.id && !leg && p.amount !== 0) notice(p.amount < 0 ? 'payment.refunded' : 'payment.received', p.id);
|
||||
return p;
|
||||
},
|
||||
},
|
||||
ticketUsage: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('ticket.checked_in', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('user.created', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
event: {
|
||||
async create({ args, query }) {
|
||||
const e = await query(args);
|
||||
if (e?.id && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const touches = args?.data && ('isActive' in args.data || 'isHidden' in args.data);
|
||||
if (!touches) return query(args);
|
||||
const prev = await before('event', args.where, { isActive: true, isHidden: true });
|
||||
const e = await query(args);
|
||||
if (e?.id && prev && !isPublic(prev) && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPrismaClient() {
|
||||
return withWebhooks(createBaseClient());
|
||||
}
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
|
||||
@@ -8,8 +8,12 @@ const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const { publicOptions } = require('../utils/volunteers');
|
||||
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
const seriesUtil = require('../utils/series');
|
||||
|
||||
const SERIES_SELECT = { select: { id: true, name: true, slug: true } };
|
||||
|
||||
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
|
||||
async function assertCampusAllowed(req, campusId) {
|
||||
@@ -101,6 +105,20 @@ async function saveSchedule(tx, eventId, body) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
|
||||
// @route POST /api/events/:id/copy-from
|
||||
// @access Private/Supervisor
|
||||
const copyEventSetupFrom = async (req, res) => {
|
||||
try {
|
||||
const fromEventId = req.body?.fromEventId;
|
||||
if (!fromEventId) return res.status(400).json({ message: 'fromEventId is required' });
|
||||
const copied = await require('../utils/eventClone').copyEventSetup(String(fromEventId), req.params.id, { withCosts: req.user?.role === 'admin' });
|
||||
res.json(copied);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
if (!url) return url;
|
||||
@@ -143,6 +161,7 @@ const createEvent = async (req, res) => {
|
||||
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
|
||||
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
|
||||
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
|
||||
const seriesId = await seriesUtil.seriesForEvent(req.body.seriesId);
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -180,6 +199,7 @@ const createEvent = async (req, res) => {
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesId ? { seriesId } : {}),
|
||||
...scheduleData(req.body),
|
||||
};
|
||||
if (data.scheduleType === 'sessions') {
|
||||
@@ -195,7 +215,7 @@ const createEvent = async (req, res) => {
|
||||
action: 'event_created',
|
||||
targetType: 'Event',
|
||||
targetId: createdEvent.id,
|
||||
metadata: { title: createdEvent.title },
|
||||
metadata: { title: createdEvent.title, ...(req.body.clonedFromId ? { clonedFromId: String(req.body.clonedFromId) } : {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
@@ -348,6 +368,8 @@ const getEvents = async (req, res) => {
|
||||
// ?campus=<slug or id>: that campus's events plus church-wide ones.
|
||||
const enabled = await campusesEnabled();
|
||||
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
|
||||
// ?series=<slug>: one series' events.
|
||||
if (req.query.series) campusFilter.series = { slug: String(req.query.series) };
|
||||
try {
|
||||
events = await prisma.event.findMany({
|
||||
where: {
|
||||
@@ -357,7 +379,7 @@ const getEvents = async (req, res) => {
|
||||
endDate: { gte: new Date() },
|
||||
...campusFilter,
|
||||
},
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT }
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
|
||||
@@ -374,7 +396,8 @@ const getEvents = async (req, res) => {
|
||||
|
||||
// Compute isSoldOut per event: true when every option that has a stock limit
|
||||
// is fully sold out. Events with no limited options are never sold out.
|
||||
const withStock = await Promise.all(events.map(async ev => {
|
||||
const withStock = await Promise.all(events.map(async listed => {
|
||||
const ev = { ...listed, eventOptions: publicOptions(listed) };
|
||||
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
||||
try {
|
||||
// Picked-session options have per-session limits, so they never make the event sold out.
|
||||
@@ -455,6 +478,7 @@ const getEventsAll = async (req, res) => {
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
@@ -482,7 +506,7 @@ const getEventById = async (req, res) => {
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } };
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT };
|
||||
|
||||
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
||||
// event edit screen's Notifications step; public callers don't need this.
|
||||
@@ -532,6 +556,8 @@ const getEventById = async (req, res) => {
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
@@ -635,6 +661,7 @@ const getEventById = async (req, res) => {
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
@@ -724,6 +751,9 @@ const updateEvent = async (req, res) => {
|
||||
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
|
||||
if (campusPick.error) throw new Error(campusPick.error);
|
||||
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
|
||||
const seriesPick = await seriesUtil.seriesForEvent(req.body.seriesId);
|
||||
// Moved into another series: that series' followers hear about it (once it's live).
|
||||
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
|
||||
if (campusChanged) {
|
||||
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
|
||||
}
|
||||
@@ -766,6 +796,7 @@ const updateEvent = async (req, res) => {
|
||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||
location: location !== undefined ? (location || null) : event.location,
|
||||
...(campusChanged ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||
...scheduleData(req.body, event),
|
||||
@@ -2152,6 +2183,7 @@ const getEventByAlias = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
|
||||
@@ -13,15 +13,56 @@ function compareVersions(a, b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** An update request still waiting: until this site runs the version that was asked for. */
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
|
||||
/**
|
||||
* An update request still waiting: until this site runs the version that was asked for.
|
||||
* `outdated`: a newer version than the one asked for is out, so the request can be changed to it.
|
||||
*/
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion, latestVersion = null) {
|
||||
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
|
||||
return { requestedAt, version: requestedVersion };
|
||||
return { requestedAt, version: requestedVersion, outdated: !!latestVersion && compareVersions(latestVersion, requestedVersion) > 0 };
|
||||
}
|
||||
|
||||
const str = (v, max) => String(v ?? '').slice(0, max);
|
||||
|
||||
/** What's new in each version newer than this one (from the Hub), newest first. */
|
||||
function newerReleases(releases, version) {
|
||||
return (releases || [])
|
||||
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
|
||||
.sort((a, b) => compareVersions(b.version, a.version))
|
||||
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
|
||||
* serious bug fixes) can't be put off.
|
||||
*/
|
||||
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
|
||||
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
|
||||
}
|
||||
|
||||
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
|
||||
|
||||
/** The provider's notices showing now: started (or no start) and not ended. */
|
||||
function activeNotices(notices, now = new Date()) {
|
||||
return (notices || [])
|
||||
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
|
||||
.map(n => ({
|
||||
id: str(n.id, 64),
|
||||
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
|
||||
title: str(n.title, 120),
|
||||
message: str(n.message, 2000),
|
||||
startsAt: n.startsAt || null,
|
||||
endsAt: n.endsAt || null,
|
||||
updatedAt: n.updatedAt || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** What the admin pages show: licence state, Hub connection, version and update. */
|
||||
async function statusPayload({ fresh = false } = {}) {
|
||||
const s = await licenceStatus({ fresh });
|
||||
const releases = newerReleases(s.releases, APP_VERSION);
|
||||
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
|
||||
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
|
||||
return {
|
||||
state: s.state,
|
||||
readOnly: s.readOnly,
|
||||
@@ -45,8 +86,12 @@ async function statusPayload({ fresh = false } = {}) {
|
||||
lastError: s.lastError,
|
||||
version: APP_VERSION,
|
||||
latestVersion: s.latestVersion,
|
||||
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
|
||||
updateAvailable,
|
||||
urgentUpdate,
|
||||
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
|
||||
releases,
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
|
||||
notices: activeNotices(s.notices),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,8 +142,11 @@ const heartbeatNow = async (req, res) => {
|
||||
const requestUpdate = async (req, res) => {
|
||||
try {
|
||||
const result = await hub.requestUpdate();
|
||||
if (!result.alreadyRequested) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
|
||||
if (!result.alreadyRequested || result.changed) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub',
|
||||
metadata: { from: APP_VERSION, to: result.version, ...(result.changed ? { changedFrom: result.previousVersion || null } : {}) }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
@@ -107,4 +155,49 @@ const requestUpdate = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
|
||||
// @desc Withdraw the update request (the provider is told not to update after all)
|
||||
// @route POST /api/hub/update-request/cancel
|
||||
// @access Private/Admin
|
||||
const cancelUpdateRequest = async (req, res) => {
|
||||
try {
|
||||
await hub.cancelUpdateRequest();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
|
||||
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
|
||||
// @route POST /api/hub/update-snooze body: { snooze }
|
||||
// @access Private/Admin
|
||||
const snoozeUpdate = async (req, res) => {
|
||||
try {
|
||||
const snooze = req.body?.snooze !== false;
|
||||
const current = await statusPayload({ fresh: true });
|
||||
if (snooze) {
|
||||
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
|
||||
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
|
||||
}
|
||||
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The provider's notices showing now (banner for site admins and supervisors)
|
||||
// @route GET /api/hub/notices
|
||||
// @access Private/Supervisor
|
||||
const getNotices = async (req, res) => {
|
||||
try {
|
||||
const s = await licenceStatus();
|
||||
res.json(activeNotices(s.notices));
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
|
||||
@@ -13,6 +13,7 @@ const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
const { reservedOptionIds } = require('../utils/volunteers');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
@@ -237,9 +238,11 @@ const createRegistration = async (req, res) => {
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
if (!eventOption || reserved.has(eventOption.id)) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
@@ -1507,6 +1510,18 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
|
||||
}
|
||||
}
|
||||
// The kids check-in and volunteer tickets are handed out by staff: the owner can keep (or
|
||||
// lower) the places they were given, not add more.
|
||||
if (!isStaffRole(req.user.role)) {
|
||||
for (const id of reservedOptionIds(registration.event)) {
|
||||
const had = registration.registrationOptions.filter(ro => ro.eventOptionId === id).reduce((n, ro) => n + (ro.quantity || 0), 0);
|
||||
const asked = options.filter(o => o.eventOptionId === id).reduce((n, o) => n + o.quantity, 0);
|
||||
if (asked > had) {
|
||||
res.status(400);
|
||||
throw new Error('One or more options do not belong to this event');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picked-session lines aren't edited here: leave them exactly as they are (they still
|
||||
// count towards the total due).
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { scopeFor, canManage } = require('../utils/campusScope');
|
||||
const passes = require('../utils/seasonPass');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
const audit = (req, pass, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
|
||||
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
async function assertCampuses(req, campusIds) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && campusIds.some(c => !canManage(scope, c))) {
|
||||
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// @desc A series' passes with their events, shares, and how many were sold
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
// @access Private/Supervisor
|
||||
const listPasses = async (req, res) => {
|
||||
try {
|
||||
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
|
||||
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
res.json(rows.map(p => ({
|
||||
...passes.passView(p),
|
||||
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
|
||||
// @access Private/Supervisor
|
||||
const createPass = async (req, res) => {
|
||||
try {
|
||||
const seriesId = String(req.body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
|
||||
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
|
||||
await assertCampuses(req, items.map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({
|
||||
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
|
||||
});
|
||||
audit(req, pass, 'created', { events: items.length, price: pass.price });
|
||||
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Change a pass. Its events can only change while nobody has bought it.
|
||||
// @route PUT /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const updatePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
|
||||
}
|
||||
if (items) await assertCampuses(req, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
res.json(passes.passView(await passes.loadPass(current.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a pass nobody has bought (otherwise switch it off)
|
||||
// @route DELETE /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const deletePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
|
||||
}
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
// @access Private
|
||||
const buy = async (req, res) => {
|
||||
try {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
|
||||
const back = `${frontendUrl()}/series/${pass.series.slug}`;
|
||||
const result = await passes.buyPass(req.params.id, req.user.id, {
|
||||
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
|
||||
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Pay what's still owed on a purchase (a new checkout)
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
// @access Private (the buyer)
|
||||
const payPurchase = async (req, res) => {
|
||||
try {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
|
||||
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
|
||||
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
|
||||
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc The signed-in person's pass purchases
|
||||
// @route GET /api/season-passes/mine
|
||||
// @access Private
|
||||
const myPurchases = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.seasonPassPurchase.findMany({
|
||||
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
|
||||
@@ -0,0 +1,126 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
|
||||
const { scopeFor, canView, eventWhere } = require('../utils/campusScope');
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
|
||||
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
|
||||
});
|
||||
|
||||
const audit = (req, action, series, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
|
||||
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// @desc Every series, with its events (the ones this person may see) and number of followers
|
||||
// @route GET /api/series
|
||||
// @access Private/Supervisor
|
||||
const listSeries = async (req, res) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
|
||||
res.json(rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => canView(scope, e.campusId)),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Create a series
|
||||
// @route POST /api/series
|
||||
// @access Private/Supervisor
|
||||
const createSeries = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
|
||||
audit(req, 'series_created', series);
|
||||
res.status(201).json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Rename a series or change its page
|
||||
// @route PUT /api/series/:id
|
||||
// @access Private/Supervisor
|
||||
const updateSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
const data = cleanSeriesInput(req.body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
res.json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a series (its events stay, just without a series; followers are dropped)
|
||||
// @route DELETE /api/series/:id
|
||||
// @access Private/Admin
|
||||
const deleteSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
return res.status(409).json({ message: 'People have bought season passes for this series, so it can\'t be deleted.' });
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc A series' public page: its details, upcoming and past public events, and whether the
|
||||
// signed-in person follows it
|
||||
// @route GET /api/series/by-slug/:slug
|
||||
// @access Public
|
||||
const getSeriesBySlug = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.findUnique({
|
||||
where: { slug: String(req.params.slug) },
|
||||
select: { id: true, name: true, slug: true, description: true, picture: true },
|
||||
});
|
||||
if (!series) return res.status(404).json({ message: 'Series not found' });
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = splitEvents(events);
|
||||
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
|
||||
// Season passes switched on (1.47.3); `unavailable` says why one can't be bought now.
|
||||
const { passView, PASS_INCLUDE } = require('../utils/seasonPass');
|
||||
const passes = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: PASS_INCLUDE }))
|
||||
.map(p => passView(p))
|
||||
.map(({ items, ...p }) => ({ ...p, items: items.map(({ campusId, ...i }) => i) }));
|
||||
res.json({ ...series, upcoming, past, following, passes });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
|
||||
// (the events this person may see), oldest first, with the change from the one before
|
||||
// @route GET /api/series/:id/compare
|
||||
// @access Private/Supervisor
|
||||
const compare = async (req, res) => {
|
||||
try {
|
||||
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
|
||||
// @access Private
|
||||
const followSeries = async (req, res) => {
|
||||
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
const unfollowSeries = async (req, res) => {
|
||||
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
|
||||
@@ -1114,6 +1114,13 @@ const activateAccount = async (req, res) => {
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
|
||||
// Without it (an older page) they're asked once after login instead.
|
||||
if (req.body.acceptTerms === true) {
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
|
||||
}
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logDataAccess } = require('../utils/accessLog');
|
||||
const { listWhere, withScope } = require('../utils/campusScope');
|
||||
const v = require('../utils/volunteers');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && typeof error.code === 'string' && !error.code.startsWith('P') ? { code: error.code } : {}) });
|
||||
const audit = (req, action, targetType, targetId, metadata) => logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action, targetType, targetId, metadata, ip: getClientIp(req) });
|
||||
const later = p => { Promise.resolve(p).catch(() => {}); };
|
||||
const CONTACT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true };
|
||||
|
||||
// ─── My account → Serving (anyone signed in) ──────────────────────────────────
|
||||
|
||||
// @desc My shifts, cover requests I could take, and events looking for volunteers
|
||||
// @route GET /api/volunteers/me
|
||||
const getMine = async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [slots, cover, events] = await Promise.all([
|
||||
v.mySlots({ userId: req.user.id, now }),
|
||||
v.openSwaps({ userId: req.user.id, now }),
|
||||
v.eventsWithRoles({ now, where: { isHidden: false, goLiveAt: { lte: now } } }),
|
||||
]);
|
||||
res.json({ slots, cover, events: events.map(e => ({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, location: e.location, roles: e._count.servingRoles })) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc An event's serving roles: how full each is, and whether I'm in it
|
||||
// @route GET /api/volunteers/events/:eventId
|
||||
const getEventRoles = async (req, res) => {
|
||||
try {
|
||||
const out = await v.roster(req.params.eventId, { userId: req.user.id });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/roles/:id/sign-up
|
||||
const signUp = async (req, res) => {
|
||||
try {
|
||||
const { slot, role, event } = await v.signUp({ roleId: req.params.id, userId: req.user.id });
|
||||
// req.user has no notification preference; the message goes the way they like it.
|
||||
later(prisma.user.findUnique({ where: { id: req.user.id }, select: CONTACT }).then(user => v.signedUpMessages({ user, role, event })));
|
||||
res.status(201).json({ slotId: slot.id, ticket: !!slot.registrationId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Withdraw from a shift (the event's notify recipients are told)
|
||||
// @route DELETE /api/volunteers/slots/:id
|
||||
const withdraw = async (req, res) => {
|
||||
try {
|
||||
const out = await v.leave({ slotId: req.params.id, userId: req.user.id });
|
||||
later(v.sendLeft(out));
|
||||
res.json({ message: 'You\'re off the roster for this shift.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Can't make it": ask the event's other volunteers to take over
|
||||
// @route POST /api/volunteers/slots/:id/swap { note? }
|
||||
const requestSwap = async (req, res) => {
|
||||
try {
|
||||
const out = await v.requestSwap({ slotId: req.params.id, userId: req.user.id, note: req.body?.note });
|
||||
if (out.created) later(v.sendSwapRequest(out));
|
||||
res.status(out.created ? 201 : 200).json({ swapId: out.swap.id });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/volunteers/swaps/:id
|
||||
const cancelSwap = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.cancelSwap({ swapId: req.params.id, userId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take over someone's place. Happens at once; the event's notify recipients are told.
|
||||
// @route POST /api/volunteers/swaps/:id/accept
|
||||
const acceptSwap = async (req, res) => {
|
||||
try {
|
||||
const out = await v.acceptSwap({ swapId: req.params.id, userId: req.user.id });
|
||||
later(v.sendSwapAccepted(out));
|
||||
res.json({ slotId: out.slot.id, role: out.slot.role.name, event: out.event.title });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check in by scanning the event's volunteer QR code
|
||||
// @route POST /api/volunteers/check-in { qr }
|
||||
const checkIn = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.checkInWithQr({ qr: req.body?.qr, userId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff: the roster on the day ─────────────────────────────────────────────
|
||||
|
||||
// @desc Events with serving roles (or upcoming, for supervisors setting them up)
|
||||
// @route GET /api/volunteers/manage/events?all=1
|
||||
const listEvents = async (req, res) => {
|
||||
try {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const base = req.query.all === '1' && ['admin', 'supervisor'].includes(req.user.role)
|
||||
? { endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }
|
||||
: { endDate: { gte: since }, servingRoles: { some: {} } };
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope(base, await listWhere(req)),
|
||||
select: { id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true, _count: { select: { servingRoles: true } } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(events.map(e => ({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, scheduleType: e.scheduleType, campusId: e.campusId, roles: e._count.servingRoles })));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The roster sheet: roles with their volunteers, contact details and check-ins
|
||||
// @route GET /api/volunteers/manage/events/:eventId?log=1
|
||||
const getRoster = async (req, res) => {
|
||||
try {
|
||||
const out = await v.roster(req.params.eventId, { staff: true, userId: req.user.id });
|
||||
if (req.query.log === '1') {
|
||||
logDataAccess(req, { action: 'volunteer_roster', eventId: out.event.id, records: out.roles.reduce((n, r) => n + r.volunteers.length, 0) });
|
||||
}
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The check-in QR code volunteers scan on the day
|
||||
// @route GET /api/volunteers/manage/events/:eventId/qr
|
||||
const getQr = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.checkInQr(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/manage/slots/:id/check-in { undo? }
|
||||
const checkInManual = async (req, res) => {
|
||||
try {
|
||||
const out = await v.checkInManual({ slotId: req.params.id, staffUserId: req.user.id, undo: !!req.body?.undo });
|
||||
if (out.changed) audit(req, 'volunteer_checked_in_manual', 'volunteer_slot', out.slot.id, { volunteer: out.slot.user.name, role: out.slot.role.name, undo: !!req.body?.undo });
|
||||
res.json({ slotId: out.slot.id, changed: out.changed });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Supervisors: roles, people, settings, reports ────────────────────────────
|
||||
|
||||
// @route GET /api/volunteers/manage/events/:eventId/settings
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
res.json(await v.getSettings(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Free volunteer place on/off: { optionId } ('new' adds a free "Volunteer" ticket, null = off)
|
||||
// @route PUT /api/volunteers/manage/events/:eventId/settings
|
||||
const saveSettings = async (req, res) => {
|
||||
try {
|
||||
const out = await v.saveSettings(req.params.eventId, { optionId: req.body?.optionId ?? null });
|
||||
audit(req, 'volunteer_settings_updated', 'event', out.event.id, { volunteerOptionId: out.event.volunteerOptionId });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/volunteers/manage/events/:eventId/roles { name, needed, sessionId?, startsAt?, endsAt?, description?, kidsRoomId? }
|
||||
const createRole = async (req, res) => {
|
||||
try {
|
||||
const role = await v.createRole(req.params.eventId, req.body);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', role.id, { eventId: role.eventId, created: role.name, needed: role.needed });
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/volunteers/manage/roles/:id
|
||||
const updateRole = async (req, res) => {
|
||||
try {
|
||||
const role = await v.updateRole(req.params.id, req.body);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', role.id, { eventId: role.eventId, updated: role.name, needed: role.needed });
|
||||
res.json(role);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/volunteers/manage/roles/:id (its volunteers are told)
|
||||
const deleteRole = async (req, res) => {
|
||||
try {
|
||||
const out = await v.deleteRole(req.params.id);
|
||||
audit(req, 'volunteer_roles_changed', 'serving_role', out.role.id, { eventId: out.role.eventId, deleted: out.role.name, volunteers: out.volunteers.length });
|
||||
later(v.sendRoleDeleted(out));
|
||||
res.json({ message: 'Deleted', volunteers: out.volunteers.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put someone on a role (allowed beyond the number needed); they're told
|
||||
// @route POST /api/volunteers/manage/roles/:id/assign { userId }
|
||||
const assign = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: String(req.body?.userId || '') }, select: CONTACT });
|
||||
if (!user) throw v.volunteerError('Choose who to put on the roster.', 404);
|
||||
const { slot, role, event } = await v.signUp({ roleId: req.params.id, userId: user.id, assignedById: req.user.id });
|
||||
audit(req, 'volunteer_assigned', 'volunteer_slot', slot.id, { eventId: event.id, role: role.name, volunteer: user.name });
|
||||
if (user.isActive) later(v.signedUpMessages({ user, role, event, by: req.user.name }));
|
||||
res.status(201).json({ slotId: slot.id, ticket: !!slot.registrationId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off a role (not after they've checked in); they're told
|
||||
// @route DELETE /api/volunteers/manage/slots/:id
|
||||
const remove = async (req, res) => {
|
||||
try {
|
||||
const out = await v.leave({ slotId: req.params.id });
|
||||
audit(req, 'volunteer_removed', 'volunteer_slot', out.slot.id, { eventId: out.event.id, role: out.slot.role.name, volunteer: out.slot.user.name });
|
||||
later(v.sendLeft({ ...out, byStaff: req.user.name }));
|
||||
res.json({ message: 'Removed' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Hours served per person (checked-in shifts), over the events the user can see
|
||||
// @route GET /api/volunteers/manage/hours?eventId=&from=&to=
|
||||
const hours = async (req, res) => {
|
||||
try {
|
||||
const scope = await listWhere(req);
|
||||
const eventWhere = withScope(req.query.eventId ? { id: String(req.query.eventId) } : {}, scope);
|
||||
const rows = await v.hoursServed({ eventWhere, from: req.query.from || null, to: req.query.to || null });
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMine, getEventRoles, signUp, withdraw, requestSwap, cancelSwap, acceptSwap, checkIn,
|
||||
listEvents, getRoster, getQr, checkInManual,
|
||||
getSettings, saveSettings, createRole, updateRole, deleteRole, assign, remove, hours,
|
||||
};
|
||||
+59
-12
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
|
||||
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
|
||||
* Install → Hub only. Stored in AppSetting:
|
||||
* signed heartbeat every 15 minutes, and a signed update request (or withdrawal) when an admin
|
||||
* asks. Install → Hub only. Stored in AppSetting:
|
||||
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
|
||||
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
|
||||
* hub_update_requested_at, hub_update_requested_version.
|
||||
* hub_releases, hub_notices (JSON from the last heartbeat: what's new, provider's notices),
|
||||
* hub_update_requested_at, hub_update_requested_version,
|
||||
* hub_update_snoozed_version ("Not now" for that version; set here, not by the Hub).
|
||||
* These keys are kept out of the generic settings endpoints (settingsController).
|
||||
*/
|
||||
|
||||
@@ -16,7 +18,7 @@ const report = require('./report');
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
const TIMEOUT_MS = 15000;
|
||||
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
|
||||
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
|
||||
|
||||
function hubUrl() {
|
||||
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
|
||||
@@ -38,6 +40,14 @@ async function save(values) {
|
||||
clearLicenceCache();
|
||||
}
|
||||
|
||||
/** What's new and the provider's notices from a register/heartbeat reply (a Hub before 1.4.0 sends neither). */
|
||||
function fromHub(data) {
|
||||
return {
|
||||
hub_releases: Array.isArray(data.releases) ? JSON.stringify(data.releases.slice(0, 20)) : null,
|
||||
hub_notices: Array.isArray(data.notices) ? JSON.stringify(data.notices.slice(0, 20)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function read(keys) {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
@@ -101,6 +111,7 @@ async function connect(licenceKey, adminContact) {
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
...fromHub(data),
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
@@ -119,6 +130,7 @@ async function heartbeat() {
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
...fromHub(data),
|
||||
});
|
||||
return { ok: true, status: claims?.status || null };
|
||||
} catch (e) {
|
||||
@@ -133,22 +145,57 @@ async function heartbeat() {
|
||||
}
|
||||
}
|
||||
|
||||
/** a < b as x.y.z versions. */
|
||||
function olderThan(a, b) {
|
||||
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
|
||||
* Returns { requestedAt, version, alreadyRequested }.
|
||||
* Asking again after a newer version came out moves the request on to it (`changed`). A Hub before
|
||||
* 1.5.0 can't do that (it doesn't send `changed`), so the old request is withdrawn and made again.
|
||||
* Returns { requestedAt, version, alreadyRequested, changed, previousVersion }.
|
||||
*/
|
||||
async function requestUpdate() {
|
||||
let data;
|
||||
try {
|
||||
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
const ask = async () => {
|
||||
try {
|
||||
return await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
let data = await ask();
|
||||
if (data.alreadyRequested && data.changed === undefined) {
|
||||
const asked = (await read(['hub_update_requested_version'])).hub_update_requested_version || null;
|
||||
if (!asked || olderThan(asked, data.version)) {
|
||||
await cancelUpdateRequest();
|
||||
data = { ...(await ask()), changed: true, previousVersion: asked };
|
||||
}
|
||||
}
|
||||
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Withdraw the update request (§3a): the provider is told not to update after all. */
|
||||
async function cancelUpdateRequest() {
|
||||
try {
|
||||
await signedPost('/api/installs/update-request/cancel', { protocolVersion: PROTOCOL_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub can\'t withdraw update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
}
|
||||
await save({ hub_update_requested_at: null, hub_update_requested_version: null });
|
||||
}
|
||||
|
||||
/** "Not now": no update reminders until a newer version than `version` is out. Null turns them back on. */
|
||||
async function snoozeUpdate(version) {
|
||||
await save({ hub_update_snoozed_version: version || null });
|
||||
}
|
||||
|
||||
/**
|
||||
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
|
||||
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
|
||||
@@ -164,4 +211,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
|
||||
@@ -77,10 +77,19 @@ function checkDisabled() {
|
||||
|
||||
let cache = null;
|
||||
|
||||
function parseList(json) {
|
||||
try {
|
||||
const list = JSON.parse(json || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
|
||||
async function licenceStatus({ fresh = false } = {}) {
|
||||
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'] } } });
|
||||
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
const claims = verifyToken(s.hub_licence);
|
||||
const value = {
|
||||
@@ -95,6 +104,9 @@ async function licenceStatus({ fresh = false } = {}) {
|
||||
latestVersion: s.hub_latest_version || null,
|
||||
updateRequestedAt: s.hub_update_requested_at || null,
|
||||
updateRequestedVersion: s.hub_update_requested_version || null,
|
||||
updateSnoozedVersion: s.hub_update_snoozed_version || null,
|
||||
releases: parseList(s.hub_releases),
|
||||
notices: parseList(s.hub_notices),
|
||||
};
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
|
||||
@@ -13,6 +13,14 @@ const { licenceStatus } = require('./licence');
|
||||
const BLOCKED = [
|
||||
['POST', /^\/api\/events\/?$/],
|
||||
['PUT', /^\/api\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
|
||||
['POST', /^\/api\/series\/?$/],
|
||||
['PUT', /^\/api\/series\/[^/]+\/?$/],
|
||||
// Season passes: new or changed passes, and buying one (it makes registrations and payments).
|
||||
['POST', /^\/api\/season-passes\/?$/],
|
||||
['PUT', /^\/api\/season-passes\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/season-passes\/[^/]+\/buy\/?$/],
|
||||
['POST', /^\/api\/season-passes\/purchases\/[^/]+\/checkout\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
|
||||
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
|
||||
@@ -26,6 +34,10 @@ const BLOCKED = [
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
|
||||
@@ -184,6 +184,10 @@ app.get('/health', async (req, res) => {
|
||||
app.use('/api/hub', require('./routes/hubRoutes'));
|
||||
app.use('/api/campuses', require('./routes/campusRoutes'));
|
||||
app.use('/api/kids', require('./routes/kidsRoutes'));
|
||||
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/series', require('./routes/seriesRoutes'));
|
||||
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
|
||||
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -583,6 +587,10 @@ app.get('/docs', async (req, res) => {
|
||||
pathParams:{ ':id':'Event UUID' },
|
||||
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
|
||||
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
|
||||
{ method:'POST', path:'/api/events/:id/copy-from', auth:'supervisor+', desc:'Copying an event (1.47.0): after the copy is created from the event form, copy the source event\'s allocation sets and groups (not the kids rooms) and, for admins, its planned costs (matched to options by name). Only into an event with no registrations or allocation sets yet. POST /api/events takes clonedFromId to note the source in the audit log.',
|
||||
pathParams:{ ':id':'The new event' },
|
||||
request:{ body:{ fromEventId:'ev-uuid-source' }},
|
||||
responses:[{ status:200, desc:'Copied', body:{ sets:2, groups:9, costs:3 }}, { status:409, desc:'The event already has registrations or groups' }]},
|
||||
{ method:'GET', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Get which users receive registration/payment/daily-summary notifications for this event. Lightweight — skips the option/stock computation that GET /api/events/:id does.',
|
||||
pathParams:{ ':id':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' }]}]},
|
||||
@@ -925,6 +933,13 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
|
||||
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
|
||||
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
|
||||
{ method:'POST', path:'/api/hub/update-request/cancel', auth:'admin', desc:'Withdraw the update request (the provider is told not to update after all)',
|
||||
responses:[{ status:200, desc:'Status, updateRequest null' }, { status:502, desc:'Hub unreachable, or a Hub too old to withdraw requests (NOT_SUPPORTED)' }]},
|
||||
{ method:'POST', path:'/api/hub/update-snooze', auth:'admin', desc:'"Not now": no update reminders until a newer version is out. { snooze: false } turns them back on',
|
||||
request:{ body:{ snooze:true } },
|
||||
responses:[{ status:200, desc:'Status', body:{ updateAvailable:true, updateSnoozed:true } }, { status:400, desc:'No update, or an urgent one (can\'t be put off)' }]},
|
||||
{ method:'GET', path:'/api/hub/notices', auth:'supervisor', desc:'The provider\'s notices showing now (the dashboard banner for site admins and supervisors)',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'…', level:'maintenance', title:'Planned maintenance', message:'The site will be down for 30 minutes.', startsAt:'2026-11-01T16:00:00.000Z', endsAt:'2026-11-01T16:30:00.000Z' }] }]},
|
||||
]},
|
||||
|
||||
{ title: 'Campuses', base: '/api/campuses', endpoints: [
|
||||
@@ -985,6 +1000,68 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
|
||||
]},
|
||||
|
||||
{ title: 'Event series', base: '/api/series', endpoints: [
|
||||
{ method:'GET', path:'/api/series', auth:'supervisor+', desc:'Every series with followers, eventCount and its events (campus-scoped staff see only their campuses\' events in each)' },
|
||||
{ method:'POST', path:'/api/series', auth:'supervisor+', desc:'Create a series (slug made from the name when not given). Audited as series_created',
|
||||
request:{ body:{ name:'Family Camp', slug:'family-camp', description:'Every year in March', picture:null } } },
|
||||
{ method:'PUT', path:'/api/series/:id', auth:'supervisor+', desc:'Change a series. Audited as series_updated' },
|
||||
{ method:'DELETE', path:'/api/series/:id', auth:'admin', desc:'Delete a series; its events stay without a series and followers are dropped. Audited as series_deleted' },
|
||||
{ method:'GET', path:'/api/series/by-slug/:slug', auth:'public', desc:'The series page: details, upcoming and past public events, and following (signed in)' },
|
||||
{ method:'POST', path:'/api/series/:id/follow', auth:'user', desc:'Follow a series: an email (News & announcements) when a new event in it goes live' },
|
||||
{ method:'DELETE', path:'/api/series/:id/follow', auth:'user', desc:'Unfollow a series' },
|
||||
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
|
||||
]},
|
||||
|
||||
{ title: 'Outbound webhooks', base: '/api/outbound-webhooks', endpoints: [
|
||||
{ method:'GET', path:'/api/outbound-webhooks', auth:'admin', desc:'Endpoints (with last-week delivery counts per status) and the event types: registration.created, registration.paid, registration.cancelled, payment.received, payment.refunded, ticket.checked_in, user.created, event.published' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks', auth:'admin', desc:'Add an endpoint (https, public address). The answer holds the signing secret — shown only now. Also added to the POPIA operator register. Audited as webhook_endpoint_changed',
|
||||
request:{ body:{ name:'ChurchSuite sync', url:'https://example.com/hooks/cross-events', events:['registration.paid','ticket.checked_in'] } } },
|
||||
{ method:'PUT', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Change name, url, events or isActive' },
|
||||
{ method:'DELETE', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Delete an endpoint and its deliveries (its operator entry is marked inactive)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/rotate-secret', auth:'admin', desc:'A new signing secret (the old one stops at once)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/test', auth:'admin', desc:'Send a webhook.test now; answers { status, statusCode, error }' },
|
||||
{ method:'GET', path:'/api/outbound-webhooks/:id/deliveries?status=&limit=', auth:'admin', desc:'Delivery log, newest first, with payloads (kept 30 days)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/deliveries/:id/resend', auth:'admin', desc:'Send a delivery again now' },
|
||||
]},
|
||||
|
||||
{ title: 'Season passes', base: '/api/season-passes', endpoints: [
|
||||
{ method:'GET', path:'/api/season-passes?seriesId=', auth:'supervisor+', desc:'A series\x27 passes: events, normal prices, each event\x27s share, sold and unpaid counts' },
|
||||
{ method:'POST', path:'/api/season-passes', auth:'supervisor+', desc:'Create a pass. Events must be in the series; not youth (consent) events, staff-only or per-session tickets. Campus-scoped supervisors: only their campuses\x27 events. Audited as season_pass_changed',
|
||||
request:{ body:{ seriesId:'series-uuid', name:'2027 breakfasts', price:400, saleEndsAt:null, items:[{ eventId:'ev-uuid', eventOptionId:'opt-uuid', variantId:null }] } } },
|
||||
{ method:'PUT', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Change a pass (events and price only while nobody has bought it; 409 otherwise)' },
|
||||
{ method:'DELETE', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Delete a pass nobody has bought' },
|
||||
{ method:'POST', path:'/api/season-passes/:id/buy', auth:'user', desc:'Buy passes: a registration at every event (lines priced at each event\x27s share), then one checkout for the total (redirectUrl), or tickets straight away when free. 409 when not on sale or an event has too few places',
|
||||
request:{ body:{ quantity:2 } }, responses:[{ status:201, desc:'Bought', body:{ purchaseId:'uuid', total:800, paid:false, redirectUrl:'https://…', provider:'yoco', message:null } }] },
|
||||
{ method:'POST', path:'/api/season-passes/purchases/:id/checkout', auth:'user', desc:'Pay what is still owed on my purchase (a new checkout)' },
|
||||
{ method:'GET', path:'/api/season-passes/mine', auth:'user', desc:'My pass purchases with their registrations' },
|
||||
]},
|
||||
|
||||
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
|
||||
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
|
||||
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
|
||||
{ method:'POST', path:'/api/volunteers/roles/:id/sign-up', auth:'user', desc:'Sign up for a role. 409 ROLE_FULL when it\'s full, 409 SHIFT_CLASH when I serve elsewhere at the same time. Gives a free volunteer place when the event has one',
|
||||
responses:[{ status:201, desc:'Signed up', body:{ slotId:'s_1', ticket:true } }] },
|
||||
{ method:'DELETE', path:'/api/volunteers/slots/:id', auth:'user', desc:'Withdraw from a shift (not after checking in or once it\'s over). The event\'s notify recipients are emailed' },
|
||||
{ method:'POST', path:'/api/volunteers/slots/:id/swap', auth:'user', desc:'"Can\'t make it": ask for cover. The event\'s other volunteers are told; the request shows on everyone\'s Serving page', request:{ body:{ note:'Away that weekend' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/swaps/:id', auth:'user', desc:'Cancel my cover request' },
|
||||
{ method:'POST', path:'/api/volunteers/swaps/:id/accept', auth:'user', desc:'Take over someone\'s place. Happens at once (409 SWAP_CLOSED if someone was first); both volunteers and the event\'s notify recipients are told' },
|
||||
{ method:'POST', path:'/api/volunteers/check-in', auth:'user', desc:'Check in by scanning the event\'s volunteer QR code: every shift of mine on today is checked in; kids-room roles put me on duty in that room',
|
||||
request:{ body:{ qr:'SERVE-3f2a…' } }, responses:[{ status:200, desc:'Checked in', body:{ event:{ id:'e_1', title:'Sunday service' }, checkedIn:[{ role:'Kids', kidsRoom:'Toddlers' }], already:[] } }] },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events', auth:'staff+', desc:'Events with serving roles from the last 30 days on (?all=1, supervisors: every upcoming event). Campus-scoped' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId', auth:'staff+', desc:'The roster sheet: roles with volunteers, contact details, check-ins and fill rate. ?log=1 writes the access log' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/qr', auth:'staff+', desc:'The volunteer check-in QR code value for the event (made the first time)' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/slots/:id/check-in', auth:'staff+', desc:'Check a volunteer in by hand (undo:true to undo). Audited as volunteer_checked_in_manual' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'The volunteer ticket setting, the main tickets that can be it and the event\'s kids rooms' },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'Free volunteer place: optionId of a main ticket, "new" for a free "Volunteer" ticket, null for off (the default). Audited as volunteer_settings_updated', request:{ body:{ optionId:'new' } } },
|
||||
{ method:'POST', path:'/api/volunteers/manage/events/:eventId/roles', auth:'supervisor+', desc:'Add a serving role (sessionId required on multi-session events; shift times optional). Audited as volunteer_roles_changed',
|
||||
request:{ body:{ name:'Welcome', needed:4, sessionId:null, startsAt:'2026-11-08T07:30:00.000Z', endsAt:'2026-11-08T09:30:00.000Z', description:'Front doors', kidsRoomId:null } } },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Change a role' },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Delete a role (refused once someone checked in for it); its volunteers are told' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/roles/:id/assign', auth:'supervisor+', desc:'Put someone on a role (allowed beyond the number needed); they\'re told. Audited as volunteer_assigned', request:{ body:{ userId:'u_1' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/slots/:id', auth:'supervisor+', desc:'Take someone off a role (not after check-in); they\'re told. Audited as volunteer_removed' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/hours?eventId=&from=&to=', auth:'supervisor+', desc:'Hours served per person (checked-in shifts × shift length), most first. Campus-scoped' },
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
|
||||
@@ -45,6 +45,18 @@ const JOBS = [
|
||||
return { reminders, certificates };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
|
||||
run: () => require('../utils/volunteers').runReminderJob(),
|
||||
},
|
||||
{
|
||||
name: 'webhooks', label: 'Outbound webhooks: send what is due, retry failures, delete deliveries after 30 days', cron: '* * * * *', env: 'WEBHOOKS_JOBS_ENABLED',
|
||||
run: () => require('../utils/webhooks').runJob(),
|
||||
},
|
||||
{
|
||||
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
|
||||
run: () => require('../utils/series').runAnnounceJob(),
|
||||
},
|
||||
{
|
||||
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
|
||||
@@ -19,6 +19,12 @@ async function checkoutPaid(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
|
||||
if (tx) return true;
|
||||
// A season pass checkout: paid once its purchase is.
|
||||
const purchaseId = checkout.metadata?.purchaseId;
|
||||
if (purchaseId) {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: String(purchaseId) }, select: { status: true } });
|
||||
return purchase?.status === 'paid';
|
||||
}
|
||||
const registrationId = checkout.metadata?.registrationId;
|
||||
if (!registrationId) return false;
|
||||
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
|
||||
|
||||
@@ -71,7 +71,9 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
if (!existingRegistration) {
|
||||
const meta = tx.metadata || {};
|
||||
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
|
||||
if (!isDonation) {
|
||||
// A season pass checkout belongs to its purchase, not to one registration.
|
||||
const isSeasonPass = meta.type === 'season_pass' && !!meta.purchaseId;
|
||||
if (!isDonation && !isSeasonPass) {
|
||||
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -141,6 +143,11 @@ async function recordSucceededPayment(providerId, event, body) {
|
||||
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
|
||||
}
|
||||
|
||||
// Season pass: split over the purchase's registrations, one Payment per event.
|
||||
if (metadata.type === 'season_pass' && metadata.purchaseId) {
|
||||
return require('../utils/seasonPass').recordPassPayment(providerId, event, body);
|
||||
}
|
||||
|
||||
let registration = null;
|
||||
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
|
||||
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
@@ -29,7 +30,7 @@ const {
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
@@ -55,6 +56,9 @@ router.route('/:id')
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Clone: copy allocation groups (and costs, for admins) from the source event into a new copy.
|
||||
router.post('/:id/copy-from', protect, supervisor, scopeEvent({ need: 'manage' }), scopeBody([['fromEventId', 'event']]), copyEventSetupFrom);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
|
||||
// admin token counts, so the licence step can run during /setup.
|
||||
@@ -9,5 +10,9 @@ router.get('/status', protect, admin, getStatus);
|
||||
router.post('/connect', protect, admin, connect);
|
||||
router.post('/heartbeat', protect, admin, heartbeatNow);
|
||||
router.post('/update-request', protect, admin, requestUpdate);
|
||||
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
|
||||
router.post('/update-snooze', protect, admin, snoozeUpdate);
|
||||
// The provider's notices: site admins and supervisors see them as a banner.
|
||||
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { list, create, update, remove, rotateSecret, test, deliveries, resend } = require('../controllers/outboundWebhookController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Outbound webhooks (1.48.0): admins only. Payment provider webhooks *into* the site are /api/webhooks/<provider>.
|
||||
router.use(protect, admin);
|
||||
router.get('/', list);
|
||||
router.post('/', create);
|
||||
router.post('/deliveries/:id/resend', resend);
|
||||
router.put('/:id', update);
|
||||
router.delete('/:id', remove);
|
||||
router.post('/:id/rotate-secret', rotateSecret);
|
||||
router.post('/:id/test', test);
|
||||
router.get('/:id/deliveries', deliveries);
|
||||
|
||||
module.exports = router;
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
markBuiltInReviewed,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
@@ -64,6 +65,7 @@ router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
const { notOnSeasonPass } = require('../utils/seasonPass');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
|
||||
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
|
||||
@@ -25,10 +26,10 @@ const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
|
||||
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases } = require('../controllers/seasonPassController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a pass can span campuses; each event on it is checked against the person\'s campuses in the controller');
|
||||
|
||||
router.get('/mine', protect, myPurchases);
|
||||
router.post('/:id/buy', protect, buy);
|
||||
router.post('/purchases/:id/checkout', protect, payPurchase);
|
||||
|
||||
router.get('/', protect, supervisor, SPANS, listPasses);
|
||||
router.post('/', protect, supervisor, SPANS, createPass);
|
||||
router.put('/:id', protect, supervisor, SPANS, updatePass);
|
||||
router.delete('/:id', protect, supervisor, SPANS, deletePass);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,21 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
|
||||
|
||||
router.get('/by-slug/:slug', optionalAuth, getSeriesBySlug);
|
||||
router.post('/:id/follow', protect, followSeries);
|
||||
router.delete('/:id/follow', protect, unfollowSeries);
|
||||
|
||||
// The list filters each series' events by the person's campuses itself.
|
||||
router.get('/', protect, supervisor, SPANS, listSeries);
|
||||
router.post('/', protect, supervisor, SPANS, createSeries);
|
||||
router.put('/:id', protect, supervisor, SPANS, updateSeries);
|
||||
router.delete('/:id', protect, admin, deleteSeries);
|
||||
// Year-on-year report: only the events the person may see (filtered in the controller).
|
||||
router.get('/:id/compare', protect, supervisor, scopeList, compare);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/volunteerController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Serving (anyone with an account can volunteer; only their own slots)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.get('/events/:eventId', protect, c.getEventRoles);
|
||||
router.post('/roles/:id/sign-up', protect, c.signUp);
|
||||
router.delete('/slots/:id', protect, c.withdraw);
|
||||
router.post('/slots/:id/swap', protect, c.requestSwap);
|
||||
router.delete('/swaps/:id', protect, c.cancelSwap);
|
||||
router.post('/swaps/:id/accept', protect, c.acceptSwap);
|
||||
router.post('/check-in', protect, c.checkIn);
|
||||
|
||||
// The roster on the day (staff)
|
||||
router.get('/manage/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/manage/events/:eventId', protect, staff, scopeEvent(), c.getRoster);
|
||||
router.get('/manage/events/:eventId/qr', protect, staff, scopeEvent(), c.getQr);
|
||||
router.post('/manage/slots/:id/check-in', protect, staff, scopeEvent({ resolve: 'volunteerSlot' }), c.checkInManual);
|
||||
|
||||
// Roles, people, settings and reports (supervisors)
|
||||
router.get('/manage/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/manage/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
router.post('/manage/events/:eventId/roles', protect, supervisor, scopeEvent({ need: 'manage' }), c.createRole);
|
||||
router.put('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.updateRole);
|
||||
router.delete('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.deleteRole);
|
||||
router.post('/manage/roles/:id/assign', protect, supervisor, scopeEvent({ resolve: 'servingRole' }), c.assign);
|
||||
router.delete('/manage/slots/:id', protect, supervisor, scopeEvent({ resolve: 'volunteerSlot' }), c.remove);
|
||||
router.get('/manage/hours', protect, supervisor, scopeList, c.hours);
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,6 +17,7 @@ const ACCESS_ACTIONS = {
|
||||
impersonation: 'Viewed the site as another user',
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,8 @@ const RESOLVERS = {
|
||||
},
|
||||
kidsCheckIn: async id => shape((await prisma.kidsCheckIn.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
kidsAlert: async id => shape((await prisma.kidsAlert.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
servingRole: async id => shape((await prisma.servingRole.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
volunteerSlot: async id => shape((await prisma.volunteerSlot.findUnique({ where: { id }, select: { role: { select: { event: EV } } } }))?.role?.event),
|
||||
};
|
||||
|
||||
/** The event a record belongs to (see RESOLVERS), or null. */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Event cloning (1.47.0). The copy itself is made by the event form in the browser — it opens
|
||||
* "New event" filled in from the source event (details, sessions, options, variants, early-bird
|
||||
* tiers, form, feedback form, sections, notification recipients), so everything can be changed
|
||||
* before saving. This file copies the parts that form doesn't hold, once the new event exists:
|
||||
* allocation sets with their groups, and (admins only) the planned costs.
|
||||
*
|
||||
* Never copied: registrations and everything people did, coupons, attachments, bundles, the kids
|
||||
* check-in setup (kids ticket and rooms) and volunteer roles.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const COST_FIELDS = ['label', 'costType', 'amount', 'paidFromMethod', 'notes'];
|
||||
|
||||
/**
|
||||
* Costs for the new event. A cost tied to a ticket option moves to the new event's option with
|
||||
* the same name; when there's none it stays on the event as a whole.
|
||||
* @param {object[]} costs the source event's EventCost rows (with `eventOption.name`)
|
||||
* @param {object[]} newOptions the new event's options ({ id, name })
|
||||
*/
|
||||
function costCopies(costs, newOptions) {
|
||||
const byName = new Map(newOptions.map(o => [String(o.name).trim().toLowerCase(), o.id]));
|
||||
return costs.map(c => {
|
||||
const data = {};
|
||||
for (const f of COST_FIELDS) data[f] = c[f] ?? null;
|
||||
const name = c.eventOption?.name ? String(c.eventOption.name).trim().toLowerCase() : null;
|
||||
data.eventOptionId = (name && byName.get(name)) || null;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
/** The source's allocation sets worth copying: all but the kids rooms. */
|
||||
function setsToCopy(sets, kidsRoomSetId) {
|
||||
return sets.filter(s => s.id !== kidsRoomSetId);
|
||||
}
|
||||
|
||||
function cloneError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies allocation sets and groups (and costs when `withCosts`) from `fromId` to the just-made
|
||||
* event `toId`. Refused once the new event has registrations or allocation sets, so it can only
|
||||
* fill a fresh copy.
|
||||
* @returns {Promise<{ sets: number, groups: number, costs: number }>}
|
||||
*/
|
||||
async function copyEventSetup(fromId, toId, { withCosts = false } = {}) {
|
||||
if (fromId === toId) throw cloneError("An event can't be copied onto itself");
|
||||
const [from, to] = await Promise.all([
|
||||
prisma.event.findUnique({
|
||||
where: { id: fromId },
|
||||
select: {
|
||||
id: true, kidsRoomSetId: true,
|
||||
allocationSets: { orderBy: { order: 'asc' }, include: { groups: { orderBy: { order: 'asc' } } } },
|
||||
costs: { orderBy: { createdAt: 'asc' }, include: { eventOption: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
prisma.event.findUnique({
|
||||
where: { id: toId },
|
||||
select: { id: true, eventOptions: { select: { id: true, name: true } }, _count: { select: { registrations: true, allocationSets: true } } },
|
||||
}),
|
||||
]);
|
||||
if (!from || !to) throw cloneError('Event not found', 404);
|
||||
if (to._count.registrations > 0 || to._count.allocationSets > 0) {
|
||||
throw cloneError('Setup can only be copied into a new event (this one already has registrations or groups)', 409);
|
||||
}
|
||||
|
||||
const sets = setsToCopy(from.allocationSets, from.kidsRoomSetId);
|
||||
const costs = withCosts ? costCopies(from.costs, to.eventOptions) : [];
|
||||
let groups = 0;
|
||||
await prisma.$transaction(async tx => {
|
||||
for (const s of sets) {
|
||||
const set = await tx.allocationSet.create({ data: { id: uuidv4(), eventId: toId, name: s.name, showOnTicket: s.showOnTicket, order: s.order } });
|
||||
for (const g of s.groups) {
|
||||
await tx.allocationGroup.create({
|
||||
data: {
|
||||
id: uuidv4(), setId: set.id, name: g.name, capacity: g.capacity, gender: g.gender,
|
||||
leaderUserId: g.leaderUserId, order: g.order, minAge: g.minAge, maxAge: g.maxAge, ratio: g.ratio,
|
||||
},
|
||||
});
|
||||
groups++;
|
||||
}
|
||||
}
|
||||
for (const c of costs) await tx.eventCost.create({ data: { id: uuidv4(), eventId: toId, ...c } });
|
||||
});
|
||||
return { sets: sets.length, groups, costs: costs.length };
|
||||
}
|
||||
|
||||
module.exports = { copyEventSetup, costCopies, setsToCopy };
|
||||
@@ -584,7 +584,7 @@ async function pageParent({ checkInId, message, staffUserId }, db = prisma) {
|
||||
async function board({ eventId, sessionId = null, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion, sessionId: sid } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const [rooms, checkIns, duties, alerts] = await Promise.all([
|
||||
const [rooms, checkIns, duties, alerts, rostered] = await Promise.all([
|
||||
roomsFor(event, db),
|
||||
db.kidsCheckIn.findMany({
|
||||
where: { eventId: event.id, occasion },
|
||||
@@ -598,6 +598,9 @@ async function board({ eventId, sessionId = null, now = new Date() }, db = prism
|
||||
}),
|
||||
db.kidsRoomDuty.findMany({ where: { occasion, endedAt: null, room: { setId: event.kidsRoomSetId } }, include: { user: { select: { id: true, name: true } } } }),
|
||||
db.kidsAlert.findMany({ where: { eventId: event.id, occasion }, orderBy: { createdAt: 'desc' }, take: 50, include: { createdBy: { select: { name: true } } } }),
|
||||
// Volunteer rosters (1.46.0): who is rostered in each room. They count as leaders on duty
|
||||
// once they check in (a KidsRoomDuty with fromRoster), like "I'm on duty".
|
||||
require('./volunteers').rosteredForRooms(event, occasion, db),
|
||||
]);
|
||||
const familyIds = [...new Set(checkIns.map(c => c.pickupCode.familyId).filter(Boolean))];
|
||||
const families = familyIds.length ? await db.family.findMany({ where: { id: { in: familyIds } }, include: FAMILY_INCLUDE }) : [];
|
||||
@@ -614,7 +617,7 @@ async function board({ eventId, sessionId = null, now = new Date() }, db = prism
|
||||
sessionLabel,
|
||||
rooms: rooms.map(r => {
|
||||
const leaders = duties.filter(d => d.roomId === r.id).map(d => d.user);
|
||||
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), rostered: rostered[r.id] || [], ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
}),
|
||||
checkIns: checkIns.map(c => {
|
||||
const fam = famById[c.pickupCode.familyId];
|
||||
@@ -835,7 +838,7 @@ module.exports = {
|
||||
randomCode, normaliseCode, ageOn, fitsRoom, suggestRoom, roomStatus, stickerAlert, occasionFor, checkInMessage, listNames,
|
||||
childView, familyView, loadFamily, familyOfUser, createFamily, cleanChild, cleanAdult, custodyChanged, searchFamilies,
|
||||
loadKidsEvent, roomsFor, getSettings, saveSettings,
|
||||
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, sendCheckInMessages, pageParent,
|
||||
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, messageParent, sendCheckInMessages, pageParent,
|
||||
board, setDuty, moveRoom, activeForFamily, offlineSnapshot, syncOffline,
|
||||
removeFromFamily, exportFamily, runKidsRetention, previewKidsRetention,
|
||||
};
|
||||
|
||||
@@ -1513,6 +1513,7 @@ async function sendAttendeeFormLinkMessage(attendee) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEventNotifyEmails,
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
|
||||
@@ -211,6 +211,8 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
// A season pass line is priced at its event's share of the pass, never at the ticket's price.
|
||||
if (registration.seasonPassPurchaseId) return { changed: false };
|
||||
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
|
||||
@@ -460,7 +460,15 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
const { familyDeleted } = await require('./kids').removeFromFamily(tx, userId);
|
||||
await tx.kidsCheckIn.updateMany({ where: { checkedOutById: userId }, data: { checkedOutById: null } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted };
|
||||
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
|
||||
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
|
||||
|
||||
// Event series they followed.
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
await clearTrustedDevices(userId);
|
||||
@@ -482,7 +490,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family] = await Promise.all([
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed, seasonPasses] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -507,6 +515,9 @@ async function exportUserData(userId, db = prisma) {
|
||||
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
require('./kids').exportFamily(userId, db),
|
||||
require('./volunteers').exportVolunteering(userId, db),
|
||||
require('./series').exportFollows(userId, db),
|
||||
require('./seasonPass').exportPurchases(userId, db),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -549,6 +560,9 @@ async function exportUserData(userId, db = prisma) {
|
||||
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
|
||||
dataRequests: requests,
|
||||
kidsFamily: family,
|
||||
volunteering,
|
||||
seriesFollowed,
|
||||
seasonPasses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -605,6 +619,18 @@ async function dataExportPdf(data) {
|
||||
heading('Certificates');
|
||||
for (const c of data.certificates) para(`${c.event} — ${c.code}${c.revoked ? ' (withdrawn)' : ''}`);
|
||||
}
|
||||
if (data.volunteering?.length) {
|
||||
heading('Serving (volunteer rosters)');
|
||||
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
|
||||
}
|
||||
if (data.seasonPasses?.length) {
|
||||
heading('Season passes');
|
||||
for (const s of data.seasonPasses) para(`${fmt(s.at)} — ${s.quantity} × ${s.pass}, R${Number(s.total).toFixed(2)} (${s.status})`);
|
||||
}
|
||||
if (data.seriesFollowed?.length) {
|
||||
heading('Event series you follow');
|
||||
for (const f of data.seriesFollowed) para(`${f.series} — since ${fmt(f.since)}`);
|
||||
}
|
||||
if (data.feedback.length) {
|
||||
heading('Feedback you gave');
|
||||
for (const f of data.feedback) {
|
||||
|
||||
@@ -24,9 +24,9 @@ const DEFAULT_SECTIONS = [
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in, volunteer rosters and, for children\'s ministry, kids check-in.',
|
||||
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9 — they process payments and deliver messages for us, and any other system we connect (such as church-management software) gets only the updates we chose.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
|
||||
@@ -53,6 +53,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
|
||||
'• Your home campus, if the organisation has more than one campus and you choose one.',
|
||||
'• Serving (if you volunteer): the roles and shifts you sign up for or are given, requests to pass a shift on and who took it, when you checked in, and the hours you served.',
|
||||
'• Event series you follow, and season passes you buy (a season pass registers you for each event on it, so each shows in your registrations).',
|
||||
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
|
||||
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
@@ -90,6 +92,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
|
||||
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
|
||||
'• Show you the events of your home campus first, if the organisation has more than one campus.',
|
||||
'• Run volunteer rosters: remind you the day before you serve, pass your shift on to someone who accepts it when you can\'t make it, and check you in when you arrive.',
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
@@ -106,7 +110,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
|
||||
'• Session reminders, if the event sends them.',
|
||||
'• Session reminders, if the event sends them, and volunteer reminders the day before you serve.',
|
||||
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
@@ -133,6 +137,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'• Copies of the updates sent to connected systems (webhooks, section 9) are kept for 30 days so problems can be checked, then deleted — and straight away when your account is deleted.',
|
||||
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
@@ -161,10 +166,12 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
|
||||
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
|
||||
'• Staff running a volunteer roster see who is serving in each role, their contact details, check-ins and hours. If you ask for someone to take over your shift, your first name, the role and any note you add are shown to the event\x27s other volunteers and others who could take it over.',
|
||||
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
@@ -205,6 +212,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
|
||||
'• cross_events_dismissed_notices — notices from the software provider (such as planned maintenance) that you have closed.',
|
||||
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
@@ -230,6 +238,7 @@ const DEFAULT_SECTIONS = [
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'You are asked to agree to this notice when you create an account, or when you activate an account that was made for you (for example when someone registered you, or at the door). If you have never agreed to it — such as an account from before we asked — we ask once, the next time you log in.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
@@ -253,6 +262,49 @@ const DEFAULT_SECTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Changes to the built-in text, newest last. A site that published its own version keeps its own
|
||||
* text, so the editor lists the changes made since the revision it last reviewed
|
||||
* (`privacy_notice_builtin_reviewed`) and offers the new text section by section.
|
||||
*/
|
||||
const BUILT_IN_UPDATES = [
|
||||
{
|
||||
revision: '1.48.1',
|
||||
summary: [
|
||||
'Volunteer rosters: what is kept about people who serve, reminders, and who sees the roster.',
|
||||
'Following an event series, and season passes.',
|
||||
'Webhooks: other systems the organisation connects, what they receive, and that copies are kept 30 days.',
|
||||
'How people agree to the notice: at sign-up, at account activation, or once after login if they never have.',
|
||||
'A browser-storage item for closed provider notices.',
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
function revisionLess(a, b) {
|
||||
const pa = String(a || '0').split('.').map(Number);
|
||||
const pb = String(b || '0').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in text changes this site hasn't reviewed yet. None while nothing is published (the site
|
||||
* shows the built-in text, so it is always current).
|
||||
*/
|
||||
async function builtInUpdates(db = prisma) {
|
||||
if (!(await latestNotice(db))) return [];
|
||||
const row = await db.appSetting.findUnique({ where: { key: REVIEWED_KEY } });
|
||||
return BUILT_IN_UPDATES.filter(u => !row?.value || revisionLess(row.value, u.revision));
|
||||
}
|
||||
|
||||
/** The admin has gone through the built-in changes (taken what they want, or not). */
|
||||
async function markBuiltInReviewed(db = prisma) {
|
||||
await db.appSetting.upsert({ where: { key: REVIEWED_KEY }, update: { value: BUILT_IN_REVISION }, create: { key: REVIEWED_KEY, value: BUILT_IN_REVISION } });
|
||||
}
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
@@ -340,11 +392,17 @@ async function requiredVersion(db = prisma) {
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
/**
|
||||
* Whether someone must accept the notice before carrying on, and why: 'first' — they never
|
||||
* accepted any version (an account made for them by staff, at the door or by someone who
|
||||
* registered them, or from before acceptances were recorded); 'updated' — a version marked as a
|
||||
* significant change came out after the one they accepted. false when nothing is needed.
|
||||
*/
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
if (user.noticeVersionAccepted == null) return 'first';
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
return user.noticeVersionAccepted < required ? 'updated' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,6 +505,11 @@ async function publish({ sections, changeSummary, requiresAcceptance, publishedB
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
BUILT_IN_UPDATES,
|
||||
BUILT_IN_REVISION,
|
||||
builtInUpdates,
|
||||
markBuiltInReviewed,
|
||||
revisionLess,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Season passes (1.47.3): one price for a ticket at each of several events in a series.
|
||||
*
|
||||
* - A supervisor picks the events on the pass, and which ticket (and variant) at each.
|
||||
* - Buying registers the buyer at every event on it at once (`quantity` places each) and starts
|
||||
* one online checkout for the total. A free pass gives the tickets straight away.
|
||||
* - The price is split across the events in proportion to their normal ticket prices (whole
|
||||
* cents, `splitCents`), and each registration's line is priced at its event's share, so every
|
||||
* event's cashup and reports count only its part. Those lines are never repriced, take no
|
||||
* coupons, and can't be edited.
|
||||
* - The payment comes back as one provider payment; it's recorded as one Payment per event
|
||||
* (`recordPassPayment`), all linked to the purchase.
|
||||
* - A pass stops selling when the first event on it starts, at its sale end, or when switched off.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
function passError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const toCents = n => Math.round(Number(n || 0) * 100);
|
||||
|
||||
/**
|
||||
* Splits `totalCents` in proportion to `weights` (whole cents that add up exactly; the cents
|
||||
* left over by rounding down go to the largest remainders). All weights zero → equal parts.
|
||||
* Pure, for tests.
|
||||
*/
|
||||
function splitCents(totalCents, weights) {
|
||||
if (!weights.length) return [];
|
||||
const w = weights.map(x => Math.max(0, Number(x) || 0));
|
||||
const sum = w.reduce((s, x) => s + x, 0);
|
||||
const shares = sum > 0 ? w.map(x => (totalCents * x) / sum) : w.map(() => totalCents / w.length);
|
||||
const out = shares.map(Math.floor);
|
||||
let left = totalCents - out.reduce((s, x) => s + x, 0);
|
||||
const order = shares.map((s, i) => ({ i, frac: s - Math.floor(s) })).sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||||
for (let k = 0; left > 0; k = (k + 1) % order.length, left--) out[order[k].i]++;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The normal price of one place on an item: the variant's own price, else the option's. */
|
||||
function normalPrice(item) {
|
||||
const v = item.variant;
|
||||
return Number(v && v.price !== null && v.price !== undefined ? v.price : item.eventOption?.price || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each item's share of the pass price (rand, one pass), in proportion to its normal price.
|
||||
* Pure, for tests: items need eventOption.price (and variant.price when there is one).
|
||||
*/
|
||||
function itemShares(price, items) {
|
||||
return splitCents(toCents(price), items.map(i => toCents(normalPrice(i)))).map(c => c / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a payment goes to each registration: each gets what it still owes, in order, and
|
||||
* anything over goes to the last one. `dues` in rand. Pure, for tests.
|
||||
*/
|
||||
function allocate(dues, amount) {
|
||||
let left = toCents(amount);
|
||||
const out = dues.map(d => {
|
||||
const take = Math.min(left, Math.max(0, toCents(d)));
|
||||
left -= take;
|
||||
return take;
|
||||
});
|
||||
if (left > 0 && out.length) out[out.length - 1] += left;
|
||||
return out.map(c => c / 100);
|
||||
}
|
||||
|
||||
const ITEM_INCLUDE = {
|
||||
event: {
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, isActive: true, seriesId: true, campusId: true,
|
||||
registrationDeadline: true, scheduleType: true, attendanceMode: true, lateJoinEnabled: true, lateJoinUntilSessionId: true,
|
||||
requiresParentalConsent: true, requiresRegistration: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true,
|
||||
sessions: { select: { id: true, startsAt: true, endsAt: true, cancelledAt: true } },
|
||||
},
|
||||
},
|
||||
eventOption: { select: { id: true, name: true, price: true, stockLimit: true, eventId: true, sessionMode: true } },
|
||||
variant: { select: { id: true, name: true, price: true, stockLimit: true, eventOptionId: true } },
|
||||
};
|
||||
const PASS_INCLUDE = { items: { orderBy: { order: 'asc' }, include: ITEM_INCLUDE }, series: { select: { id: true, name: true, slug: true } } };
|
||||
|
||||
/** Why a pass can't be bought right now, or null. */
|
||||
function notOnSale(pass, now = new Date()) {
|
||||
if (!pass.isActive) return 'This pass isn\'t on sale.';
|
||||
if (!pass.items.length) return 'This pass has no events yet.';
|
||||
if (pass.saleEndsAt && new Date(pass.saleEndsAt) <= now) return 'This pass is no longer on sale.';
|
||||
const { wholeCourseClosesAt } = require('./sessions');
|
||||
for (const i of pass.items) {
|
||||
if (!i.event.isActive || i.event.cashupStatus === 'closed') return `${i.event.title} isn't taking registrations.`;
|
||||
if (new Date(i.event.startDate) <= now) return 'This pass stopped selling when its first event started.';
|
||||
if (wholeCourseClosesAt(i.event, i.event.sessions) <= now) return `Registration for ${i.event.title} has closed.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A pass as shown to buyers and supervisors: its events with normal prices and shares. */
|
||||
function passView(pass, now = new Date()) {
|
||||
const shares = itemShares(pass.price, pass.items);
|
||||
const normalValue = Math.round(pass.items.reduce((s, i) => s + normalPrice(i), 0) * 100) / 100;
|
||||
return {
|
||||
id: pass.id, seriesId: pass.seriesId, name: pass.name, description: pass.description, price: pass.price,
|
||||
isActive: pass.isActive, saleEndsAt: pass.saleEndsAt, series: pass.series,
|
||||
normalValue, saving: Math.max(0, Math.round((normalValue - pass.price) * 100) / 100),
|
||||
unavailable: notOnSale(pass, now),
|
||||
items: pass.items.map((i, k) => ({
|
||||
id: i.id, eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId,
|
||||
eventTitle: i.event.title, startDate: i.event.startDate, endDate: i.event.endDate, campusId: i.event.campusId,
|
||||
ticket: i.variant ? `${i.eventOption.name} — ${i.variant.name}` : i.eventOption.name,
|
||||
normalPrice: normalPrice(i), share: shares[k],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setting passes up ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks a create/update body against the series' events: each item is an event in the series
|
||||
* with one of its tickets (not the staff-only kids/volunteer ones, not a per-session ticket) and
|
||||
* no parental consent (a pass can't collect children's details). Returns the data to save and the
|
||||
* events on it (for the campus check).
|
||||
*/
|
||||
async function cleanPassInput(body, seriesId, current = null, db = prisma) {
|
||||
const data = {};
|
||||
if (body.name !== undefined || !current) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw passError('Give the pass a name');
|
||||
data.name = name.slice(0, 120);
|
||||
}
|
||||
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
|
||||
if (body.price !== undefined || !current) {
|
||||
const price = Number(body.price);
|
||||
if (!(price >= 0) || !Number.isFinite(price)) throw passError('Enter the pass price (0 for free)');
|
||||
data.price = Math.round(price * 100) / 100;
|
||||
}
|
||||
if (body.isActive !== undefined) data.isActive = body.isActive !== false;
|
||||
if (body.saleEndsAt !== undefined) {
|
||||
const d = body.saleEndsAt ? new Date(body.saleEndsAt) : null;
|
||||
if (d && isNaN(d.getTime())) throw passError('The sale end date isn\'t valid');
|
||||
data.saleEndsAt = d;
|
||||
}
|
||||
|
||||
let items = null;
|
||||
if (body.items !== undefined || !current) {
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) throw passError('Choose at least one event for the pass');
|
||||
const seen = new Set();
|
||||
items = [];
|
||||
for (const [order, raw] of body.items.entries()) {
|
||||
const eventId = String(raw?.eventId || '');
|
||||
if (seen.has(eventId)) throw passError('Each event can only be on a pass once');
|
||||
seen.add(eventId);
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, seriesId: true, campusId: true, requiresParentalConsent: true, requiresRegistration: true, kidsOptionId: true, volunteerOptionId: true, scheduleType: true, attendanceMode: true, eventOptions: { select: { id: true, sessionMode: true, variants: { select: { id: true } } } } },
|
||||
});
|
||||
if (!event || event.seriesId !== seriesId) throw passError('Every event on a pass must be in its series');
|
||||
if (event.requiresParentalConsent) throw passError(`${event.title} is a youth event (parental consent), so it can't be on a pass`);
|
||||
if (event.requiresRegistration === false) throw passError(`${event.title} doesn't take registrations`);
|
||||
const option = event.eventOptions.find(o => o.id === raw.eventOptionId);
|
||||
if (!option || [event.kidsOptionId, event.volunteerOptionId].includes(option.id)) throw passError(`Choose a ticket for ${event.title}`);
|
||||
if (event.scheduleType === 'sessions' && event.attendanceMode === 'pick' && option.sessionMode === 'pick') {
|
||||
throw passError(`${event.title}: choose a ticket for all sessions, not a per-session one`);
|
||||
}
|
||||
const variantId = raw.variantId ? String(raw.variantId) : null;
|
||||
if (variantId && !option.variants.some(v => v.id === variantId)) throw passError(`That variant isn't one of the ticket's at ${event.title}`);
|
||||
items.push({ eventId, eventOptionId: option.id, variantId, order, campusId: event.campusId });
|
||||
}
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function loadPass(id, db = prisma) {
|
||||
const pass = await db.seasonPass.findUnique({ where: { id }, include: PASS_INCLUDE });
|
||||
if (!pass) throw passError('Season pass not found', 404);
|
||||
return pass;
|
||||
}
|
||||
|
||||
// ─── Buying ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Buys `quantity` passes for `userId`: a registration at each event (lines priced at the share),
|
||||
* then one checkout for the total — or, for a free pass, the tickets straight away.
|
||||
* @returns {Promise<{ purchaseId, total, paid, redirectUrl, provider, message }>}
|
||||
*/
|
||||
async function buyPass(passId, userId, { quantity = 1, provider = null, successUrl, cancelUrl, failureUrl } = {}) {
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!(qty >= 1 && qty <= 20)) throw passError('Choose how many passes (1 to 20)');
|
||||
const pass = await loadPass(passId);
|
||||
const reason = notOnSale(pass);
|
||||
if (reason) throw passError(reason, 409);
|
||||
const shares = itemShares(pass.price, pass.items);
|
||||
const total = Math.round(pass.price * qty * 100) / 100;
|
||||
const free = total === 0;
|
||||
|
||||
const { remainingFor } = require('./stock');
|
||||
const purchase = await prisma.$transaction(async tx => {
|
||||
// One buyer at a time per pass, so the last places can't be sold twice through passes.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'seasonpass:' + pass.id}))`;
|
||||
for (const i of pass.items) {
|
||||
const left = await remainingFor(i.eventOption, i.variant, { db: tx });
|
||||
if (left !== null && left < qty) throw passError(`${i.event.title} has ${left === 0 ? 'no places' : `only ${left} place${left === 1 ? '' : 's'}`} left for ${i.variant ? i.variant.name : i.eventOption.name}`, 409);
|
||||
}
|
||||
const created = await tx.seasonPassPurchase.create({ data: { passId: pass.id, userId, quantity: qty, total, status: free ? 'paid' : 'pending' } });
|
||||
for (const [k, i] of pass.items.entries()) {
|
||||
const registration = await tx.registration.create({
|
||||
data: { userId, eventId: i.eventId, status: free ? 'paid' : 'pending', seasonPassPurchaseId: created.id },
|
||||
});
|
||||
const line = await tx.registrationOption.create({
|
||||
data: { registrationId: registration.id, eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: qty, priceSnapshot: shares[k] },
|
||||
});
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: qty, priceSnapshot: shares[k] } });
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
const registrations = await prisma.registration.findMany({ where: { seasonPassPurchaseId: purchase.id }, select: { id: true } });
|
||||
if (free) {
|
||||
const { generateTicketsForRegistration } = require('./ticketUtils');
|
||||
for (const r of registrations) await generateTicketsForRegistration(r.id).catch(e => console.error('[season-pass] tickets:', e?.message || e));
|
||||
emailTicketsFor(userId, registrations.map(r => r.id));
|
||||
return { purchaseId: purchase.id, total, paid: true, redirectUrl: null, provider: null, message: 'Your tickets are ready.' };
|
||||
}
|
||||
return { purchaseId: purchase.id, total, paid: false, ...(await startPassCheckout(purchase.id, userId, { provider, successUrl, cancelUrl, failureUrl })) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the online checkout for what's still owed on a purchase. When online
|
||||
* payment isn't possible (none set up, or under the minimum) the places stay booked unpaid and
|
||||
* the buyer is told to pay the church.
|
||||
*/
|
||||
async function startPassCheckout(purchaseId, userId, { provider = null, successUrl, cancelUrl, failureUrl } = {}) {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({
|
||||
where: { id: purchaseId },
|
||||
include: {
|
||||
pass: { select: { name: true } },
|
||||
user: { select: { name: true, email: true } },
|
||||
registrations: { include: { event: { select: { title: true } }, registrationOptions: { include: { tranches: true } }, payments: true } },
|
||||
},
|
||||
});
|
||||
if (!purchase || purchase.userId !== userId) throw passError('Purchase not found', 404);
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const owed = purchase.registrations
|
||||
.filter(r => r.status !== 'cancelled')
|
||||
.map(r => ({ r, due: Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)) }))
|
||||
.filter(x => x.due > 0.004);
|
||||
const amountCents = owed.reduce((s, x) => s + toCents(x.due), 0);
|
||||
if (amountCents <= 0) throw passError('This pass is already paid');
|
||||
|
||||
const payments = require('../payments');
|
||||
try {
|
||||
const checkout = await payments.createCheckout(provider, {
|
||||
amountCents,
|
||||
metadata: { type: 'season_pass', purchaseId: purchase.id, userId, passName: purchase.pass.name },
|
||||
lineItems: owed.map(x => ({ name: `${purchase.pass.name}: ${x.r.event.title}`, quantity: 1, unitCents: toCents(x.due) })),
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
payer: { name: purchase.user?.name || '', email: purchase.user?.email || '' },
|
||||
description: purchase.pass.name,
|
||||
idempotencyKey: `pass_${purchase.id}_${amountCents}_${Date.now()}`,
|
||||
});
|
||||
await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { checkoutId: checkout.checkoutId } });
|
||||
return { redirectUrl: checkout.redirectUrl, provider: checkout.provider, message: null };
|
||||
} catch (e) {
|
||||
// Online payment not possible (switched off, not set up, or under the minimum): keep the places.
|
||||
if (e.statusCode && (e.statusCode < 500 || e.statusCode === 503)) {
|
||||
return { redirectUrl: null, provider: null, message: `Your places are booked. ${e.message}` };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function emailTicketsFor(userId, registrationIds) {
|
||||
(async () => {
|
||||
const { emailTickets } = require('../controllers/ticketController');
|
||||
for (const registrationId of registrationIds) {
|
||||
const mockReq = { user: { id: userId }, body: { registrationId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('[season-pass] emailing tickets:', e?.message || e); }
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pass checkout was paid (webhook or status check): one Payment per registration on the
|
||||
* purchase for what it owed, all linked to the purchase, then each registration's status and
|
||||
* tickets. Idempotent on the provider's payment id (the first Payment carries it).
|
||||
*/
|
||||
async function recordPassPayment(providerId, event, body) {
|
||||
const { externalId, amountCents, status, metadata = {}, methodType } = event.transaction;
|
||||
const webhook = require('../payments/webhook');
|
||||
const existing = await prisma.payment.findFirst({ where: { externalId } });
|
||||
if (existing) {
|
||||
await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existing.id }).catch(() => {});
|
||||
return { status: 'already_processed', paymentId: existing.id, externalId };
|
||||
}
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({
|
||||
where: { id: String(metadata.purchaseId || '') },
|
||||
include: { registrations: { orderBy: { createdAt: 'asc' }, include: { registrationOptions: { include: { tranches: true } }, payments: true } } },
|
||||
});
|
||||
if (!purchase) throw passError(`Season pass purchase ${metadata.purchaseId} not found`, 404);
|
||||
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const regs = purchase.registrations.filter(r => r.status !== 'cancelled');
|
||||
const dues = regs.map(r => Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)));
|
||||
const parts = allocate(dues, (amountCents || 0) / 100);
|
||||
const method = methodType || 'card';
|
||||
const paymentStatus = status === 'succeeded' ? 'completed' : status;
|
||||
|
||||
const created = [];
|
||||
for (const [k, r] of regs.entries()) {
|
||||
if (!(parts[k] > 0)) continue;
|
||||
created.push(await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), amount: parts[k], method, provider: providerId, status: paymentStatus,
|
||||
externalId: created.length === 0 ? externalId : null,
|
||||
registrationId: r.id, eventId: r.eventId, userId: purchase.userId, recordedById: purchase.userId,
|
||||
seasonPassPurchaseId: purchase.id,
|
||||
},
|
||||
}));
|
||||
}
|
||||
const paidIds = [];
|
||||
for (const r of regs) {
|
||||
const updated = await webhook.updateRegistrationStatus(r.id);
|
||||
if (updated.status === 'paid') paidIds.push(r.id);
|
||||
}
|
||||
if (paidIds.length === regs.length) await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { status: 'paid' } });
|
||||
if (created[0]) await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: created[0].id }).catch(() => {});
|
||||
|
||||
(async () => {
|
||||
const { sendPaymentEmails } = require('./notifications');
|
||||
for (const p of created) {
|
||||
try { await sendPaymentEmails(p.id); } catch (e) { console.error('[season-pass] payment email:', e?.message || e); }
|
||||
}
|
||||
})();
|
||||
emailTicketsFor(purchase.userId, paidIds);
|
||||
return { status: 'success', purchaseId: purchase.id, payments: created.map(p => ({ id: p.id, amount: p.amount, eventId: p.eventId })) };
|
||||
}
|
||||
|
||||
/** Route guard: a registration from a season pass keeps its lines as bought (no edits, no coupons). */
|
||||
function notOnSeasonPass(req, res, next) {
|
||||
prisma.registration.findUnique({ where: { id: req.params.id }, select: { seasonPassPurchaseId: true } })
|
||||
.then(r => (r?.seasonPassPurchaseId
|
||||
? res.status(409).json({ message: 'This registration is part of a season pass, so its tickets can\'t be changed and codes can\'t be added. Ask the organiser if something needs to change.' })
|
||||
: next()))
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
// ─── Privacy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function exportPurchases(userId, db = prisma) {
|
||||
const rows = await db.seasonPassPurchase.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { quantity: true, total: true, status: true, createdAt: true, pass: { select: { name: true } } } });
|
||||
return rows.map(r => ({ pass: r.pass.name, quantity: r.quantity, total: r.total, status: r.status, at: r.createdAt }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
splitCents, itemShares, allocate, normalPrice, notOnSale, passView, cleanPassInput, loadPass,
|
||||
buyPass, startPassCheckout, recordPassPayment, notOnSeasonPass, exportPurchases, passError, PASS_INCLUDE,
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Event series (1.47.1): separate events grouped on one series page — every year's camp, the
|
||||
* monthly men's breakfast. A series isn't tied to a campus, so it can have events at several.
|
||||
* Each event page links to the next public event in its series, and people can follow a series:
|
||||
* when a new event in it goes live they get one email, sent as News & announcements (so only to
|
||||
* people who opted in to news, with an unsubscribe link).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { slugify } = require('./campuses');
|
||||
|
||||
function seriesError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Events anyone can see: active, not hidden and live. */
|
||||
function publicEventWhere(now = new Date()) {
|
||||
return { isActive: true, isHidden: false, goLiveAt: { lte: now } };
|
||||
}
|
||||
|
||||
/** Name, slug, description and picture from a create/update body (`current` on update). */
|
||||
function cleanSeriesInput(body = {}, current = null) {
|
||||
const data = {};
|
||||
if (body.name !== undefined || !current) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw seriesError('Give the series a name');
|
||||
if (name.length > 120) throw seriesError('The name is too long');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.slug !== undefined || !current) {
|
||||
const slug = slugify(body.slug || data.name || current?.name);
|
||||
if (!slug) throw seriesError('The web address needs letters or numbers');
|
||||
data.slug = slug;
|
||||
}
|
||||
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
|
||||
if (body.picture !== undefined) data.picture = String(body.picture || '').trim() || null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The series to save on an event from a body's `seriesId`: undefined = leave as is, null = none. */
|
||||
async function seriesForEvent(seriesId, db = prisma) {
|
||||
if (seriesId === undefined) return undefined;
|
||||
if (!seriesId) return null;
|
||||
const found = await db.eventSeries.findUnique({ where: { id: String(seriesId) }, select: { id: true } });
|
||||
if (!found) throw seriesError('That series no longer exists');
|
||||
return found.id;
|
||||
}
|
||||
|
||||
const NEXT_SELECT = { id: true, title: true, startDate: true, endDate: true, location: true, redirectUrl: true };
|
||||
|
||||
/**
|
||||
* The next public event in the same series after `event` (starting later, not over yet), or null.
|
||||
*/
|
||||
async function nextInSeries(event, db = prisma, now = new Date()) {
|
||||
if (!event?.seriesId) return null;
|
||||
return db.event.findFirst({
|
||||
where: { ...publicEventWhere(now), seriesId: event.seriesId, id: { not: event.id }, startDate: { gt: event.startDate }, endDate: { gte: now } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: NEXT_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a series' public events into upcoming (not over yet, soonest first) and past (latest
|
||||
* first). Pure, for the series page.
|
||||
*/
|
||||
function splitEvents(events, now = new Date()) {
|
||||
const upcoming = events.filter(e => new Date(e.endDate) >= now).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
||||
const past = events.filter(e => new Date(e.endDate) < now).sort((a, b) => new Date(b.startDate) - new Date(a.startDate));
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
||||
// ─── Following ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function follow(seriesId, userId) {
|
||||
const series = await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } });
|
||||
if (!series) throw seriesError('Series not found', 404);
|
||||
await prisma.seriesFollow.upsert({ where: { seriesId_userId: { seriesId, userId } }, create: { seriesId, userId }, update: {} });
|
||||
}
|
||||
|
||||
async function unfollow(seriesId, userId) {
|
||||
await prisma.seriesFollow.deleteMany({ where: { seriesId, userId } });
|
||||
}
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `series` job: for each event in a series that is now public and hasn't started, email the
|
||||
* series' followers once. The event is claimed first (seriesAnnouncedAt), so a slow run or two
|
||||
* workers never send twice. An event made public after it started is never announced.
|
||||
*/
|
||||
async function runAnnounceJob(now = new Date()) {
|
||||
const due = await prisma.event.findMany({
|
||||
where: { ...publicEventWhere(now), seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } },
|
||||
select: { id: true, title: true, startDate: true, location: true, redirectUrl: true, series: { select: { id: true, name: true, slug: true } } },
|
||||
take: 50,
|
||||
});
|
||||
let announced = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
for (const ev of due) {
|
||||
const claimed = await prisma.event.updateMany({ where: { id: ev.id, seriesAnnouncedAt: null }, data: { seriesAnnouncedAt: now } });
|
||||
if (!claimed.count) continue;
|
||||
announced++;
|
||||
const follows = await prisma.seriesFollow.findMany({
|
||||
where: { seriesId: ev.series.id, user: { isActive: true } },
|
||||
select: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
const { filterRecipients, bulkEmailFooter } = require('./privacy');
|
||||
const { recipients, skipped: notNews } = await filterRecipients(
|
||||
follows.filter(f => f.user.email).map(f => ({ email: f.user.email, name: f.user.name, userId: f.user.id })),
|
||||
{ channel: 'email', kind: 'news' },
|
||||
);
|
||||
skipped += notNews;
|
||||
const { sendMail } = require('./email');
|
||||
for (const r of recipients) {
|
||||
const footer = bulkEmailFooter({ kind: 'news', recipient: r });
|
||||
const msg = announcementEmail({ name: r.name, event: ev, series: ev.series });
|
||||
try {
|
||||
await sendMail({ to: r.email, subject: msg.subject, html: msg.html + footer.html, text: msg.text + footer.text, headers: footer.headers });
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[series] failed to email', r.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { announced, sent, skipped };
|
||||
}
|
||||
|
||||
function announcementEmail({ name, event, series }) {
|
||||
const { emailWrapper, ctaButton } = require('./email');
|
||||
const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const when = new Date(event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const url = `${frontendUrl()}/events/${event.id}`;
|
||||
const seriesUrl = `${frontendUrl()}/series/${series.slug}`;
|
||||
const subject = `New in ${series.name}: ${event.title}`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${esc(event.title)}</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 24px 0">New in ${esc(series.name)}</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">You follow <strong>${esc(series.name)}</strong>, and the next one is out: <strong>${esc(event.title)}</strong> on ${esc(when)}${event.location ? `, ${esc(event.location)}` : ''}.</p>
|
||||
${ctaButton('See the event', url)}
|
||||
<p style="margin:16px 0 0 0;font-size:13px;color:#64748b">To stop hearing about this series, unfollow it on <a href="${seriesUrl}" style="color:#64748b">its page</a>.</p>`;
|
||||
const text = `Hi ${name || 'there'},\n\nYou follow ${series.name}, and the next one is out: ${event.title} on ${when}${event.location ? `, ${event.location}` : ''}.\n\nSee the event: ${url}\n\nTo stop hearing about this series, unfollow it on its page: ${seriesUrl}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader: subject }) };
|
||||
}
|
||||
|
||||
// ─── Year-on-year comparison (1.47.2) ────────────────────────────────────────
|
||||
|
||||
// The same "real income" as the dashboard's top events: everything except the legs that apply an
|
||||
// earlier donation to an order (not new money). Refunds count, as negatives.
|
||||
const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
const pct = (now, before) => (before > 0 ? Math.round(((now - before) / before) * 100) : null);
|
||||
|
||||
/**
|
||||
* Adds `change` to each row against the row before it (rows oldest first): the percentage up or
|
||||
* down for registrations, tickets, income and checked in, null when there's nothing to compare.
|
||||
* Pure, for tests.
|
||||
*/
|
||||
function withChanges(rows) {
|
||||
return rows.map((r, i) => {
|
||||
const prev = rows[i - 1];
|
||||
return {
|
||||
...r,
|
||||
change: prev
|
||||
? { registrations: pct(r.registrations, prev.registrations), tickets: pct(r.tickets, prev.tickets), income: pct(r.income, prev.income), checkedIn: pct(r.checkedIn, prev.checkedIn) }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrations, tickets, income and attendance for each event in a series that the person may
|
||||
* see (`scopeWhere`, from campusScope.eventWhere), oldest first, with the change from the one
|
||||
* before. Checked in: check-ins on a single-date event; on a multi-session event, the average per
|
||||
* session that wasn't cancelled.
|
||||
*/
|
||||
async function compareSeries(seriesId, { scopeWhere = {} } = {}, db = prisma) {
|
||||
const series = await db.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true, name: true, slug: true } });
|
||||
if (!series) throw seriesError('Series not found', 404);
|
||||
const events = await db.event.findMany({
|
||||
where: { AND: [{ seriesId }, scopeWhere] },
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true,
|
||||
campus: { select: { id: true, name: true, slug: true, colour: true } },
|
||||
_count: { select: { sessions: { where: { cancelledAt: null } } } },
|
||||
},
|
||||
});
|
||||
const ids = events.map(e => e.id);
|
||||
const [regs, tickets, income, usages] = ids.length ? await Promise.all([
|
||||
db.registration.groupBy({ by: ['eventId'], where: { eventId: { in: ids }, status: { not: 'cancelled' } }, _count: { _all: true } }),
|
||||
db.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: ids } }, _sum: { quantity: true } }),
|
||||
db.payment.groupBy({ by: ['eventId'], where: { ...EXCLUDE_DONATION_LEGS, eventId: { in: ids } }, _sum: { amount: true } }),
|
||||
// People checked in (a group scan can let several in at once), per event through the ticket.
|
||||
Promise.all(ids.map(async eventId => ({ eventId, n: (await db.ticketUsage.aggregate({ where: { ticket: { eventId } }, _sum: { quantityRedeemed: true } }))._sum.quantityRedeemed || 0 }))),
|
||||
]) : [[], [], [], []];
|
||||
const map = (rows, pick) => new Map(rows.map(r => [r.eventId, pick(r)]));
|
||||
const regBy = map(regs, r => r._count._all);
|
||||
const ticketBy = map(tickets, r => r._sum.quantity || 0);
|
||||
const incomeBy = map(income, r => r._sum.amount || 0);
|
||||
const usageBy = map(usages, r => r.n);
|
||||
|
||||
const rows = events.map(e => {
|
||||
const scans = usageBy.get(e.id) || 0;
|
||||
const sessions = e.scheduleType === 'sessions' ? e._count.sessions : 0;
|
||||
const t = ticketBy.get(e.id) || 0;
|
||||
const checkedIn = sessions > 0 ? Math.round(scans / sessions) : scans;
|
||||
return {
|
||||
eventId: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, campus: e.campus, isActive: e.isActive,
|
||||
sessions,
|
||||
registrations: regBy.get(e.id) || 0,
|
||||
tickets: t,
|
||||
income: Math.round((incomeBy.get(e.id) || 0) * 100) / 100,
|
||||
checkedIn,
|
||||
attendanceRate: t > 0 ? Math.min(100, Math.round((checkedIn / t) * 100)) : null,
|
||||
};
|
||||
});
|
||||
return { series, events: withChanges(rows) };
|
||||
}
|
||||
|
||||
// ─── Privacy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Series someone follows, for "Download my data". */
|
||||
async function exportFollows(userId, db = prisma) {
|
||||
const rows = await db.seriesFollow.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { createdAt: true, series: { select: { name: true } } } });
|
||||
return rows.map(r => ({ series: r.series.name, since: r.createdAt }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
|
||||
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError, withChanges, compareSeries,
|
||||
};
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Volunteer rosters (1.46.0). Who serves where at an event:
|
||||
*
|
||||
* - Serving roles (Welcome, Parking, Kids, Sound…) belong to one event, or — on a multi-session
|
||||
* event — to one session, with the number of people needed and optional shift times (default:
|
||||
* the session's or event's times). Rosters never repeat: each event/session has its own roles.
|
||||
* - Anyone with an account can sign up for a role that isn't full; supervisors can also assign
|
||||
* people (even beyond the number needed). Nobody can be in two roles whose shifts overlap.
|
||||
* - Volunteers get a reminder the day before their shift. "Can't make it" asks for cover: the
|
||||
* request shows on the Serving page and goes to the event's other volunteers, and the place
|
||||
* moves as soon as someone accepts — the event's notify recipients are told, nobody approves.
|
||||
* - On the day volunteers check themselves in by scanning the event's volunteer QR code (or staff
|
||||
* tick them in). A role linked to a kids room puts its volunteers on duty in that room when they
|
||||
* check in, so they count for the room's leader-to-children ratio.
|
||||
* - A free volunteer place on a ticket of the event is off by default; a supervisor turns it on
|
||||
* per event by choosing the ticket. Volunteers who already have a ticket keep theirs.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const sessionsUtil = require('./sessions');
|
||||
|
||||
const MAX_NEEDED = 200;
|
||||
const MAX_ROLES = 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
// Reminders go out this long before a shift, to people who signed up before then.
|
||||
const REMIND_BEFORE_MS = 24 * HOUR;
|
||||
// What the check-in QR code holds: the prefix, then the event's token.
|
||||
const QR_PREFIX = 'SERVE-';
|
||||
|
||||
function volunteerError(message, statusCode = 400, code = undefined) {
|
||||
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A role's shift: its own times, else its session's, else the event's. */
|
||||
function shiftOf(role, event) {
|
||||
const session = role.session || (role.sessionId ? (event.sessions || []).find(s => s.id === role.sessionId) : null);
|
||||
return {
|
||||
startsAt: new Date(role.startsAt || session?.startsAt || event.startDate),
|
||||
endsAt: new Date(role.endsAt || session?.endsAt || event.endDate),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two shifts overlap (touching ends don't count: 08:00–10:00 and 10:00–12:00 are fine). */
|
||||
function overlaps(a, b) {
|
||||
return new Date(a.startsAt) < new Date(b.endsAt) && new Date(b.startsAt) < new Date(a.endsAt);
|
||||
}
|
||||
|
||||
/** Hours in a shift, to the nearest quarter hour. */
|
||||
function shiftHours(shift) {
|
||||
const h = (new Date(shift.endsAt) - new Date(shift.startsAt)) / HOUR;
|
||||
return h > 0 ? Math.round(h * 4) / 4 : 0;
|
||||
}
|
||||
|
||||
/** { needed, filled, open, full } for a role with `filled` volunteers. */
|
||||
function fillOf(needed, filled) {
|
||||
return { needed, filled, open: Math.max(0, needed - filled), full: filled >= needed };
|
||||
}
|
||||
|
||||
/** Volunteers can check in on the day(s) of their shift, until it ends. */
|
||||
function checkInOpen(shift, now = new Date()) {
|
||||
const today = sessionsUtil.dayKey(now);
|
||||
return today >= sessionsUtil.dayKey(shift.startsAt) && today <= sessionsUtil.dayKey(shift.endsAt) && new Date(now) <= new Date(shift.endsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a slot's reminder is due: not sent yet, the shift starts within the next 24 hours, and
|
||||
* the person signed up more than half an hour ago (someone who just signed up doesn't need one).
|
||||
*/
|
||||
function reminderDue({ remindedAt, createdAt }, shift, now = new Date()) {
|
||||
if (remindedAt) return false;
|
||||
const t = new Date(now).getTime();
|
||||
const start = new Date(shift.startsAt).getTime();
|
||||
return start > t && start - t <= REMIND_BEFORE_MS && t - new Date(createdAt).getTime() > HOUR / 2;
|
||||
}
|
||||
|
||||
/** The kids room occasion a check-in counts for (see utils/kids.js occasionFor): the session, or today. */
|
||||
function kidsOccasion(role, now = new Date()) {
|
||||
return role.sessionId || sessionsUtil.dayKey(now);
|
||||
}
|
||||
|
||||
/** Ticket options only staff hand out — the kids check-in and volunteer tickets — never sold publicly. */
|
||||
function reservedOptionIds(event) {
|
||||
return [event?.kidsOptionId, event?.volunteerOptionId].filter(Boolean);
|
||||
}
|
||||
|
||||
/** The event's options without the reserved ones (for public event pages and lists). */
|
||||
function publicOptions(event) {
|
||||
const reserved = new Set(reservedOptionIds(event));
|
||||
return (event?.eventOptions || []).filter(o => !reserved.has(o.id));
|
||||
}
|
||||
|
||||
function fmtWhen(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return new Date(d).toLocaleTimeString('en-GB', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** "Sat 7 Nov, 08:00–10:30" (the end date is added when the shift runs past midnight). */
|
||||
function shiftText(shift) {
|
||||
const sameDay = sessionsUtil.dayKey(shift.startsAt) === sessionsUtil.dayKey(shift.endsAt);
|
||||
return `${fmtWhen(shift.startsAt)}–${sameDay ? fmtTime(shift.endsAt) : fmtWhen(shift.endsAt)}`;
|
||||
}
|
||||
|
||||
function cleanText(v, max) {
|
||||
return String(v ?? '').trim().slice(0, max);
|
||||
}
|
||||
|
||||
// ─── Loading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_SELECT = {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true, location: true, campusId: true,
|
||||
kidsCheckIn: true, kidsRoomSetId: true, kidsOptionId: true, volunteerOptionId: true, volunteerCheckInToken: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
|
||||
async function loadEvent(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, select: EVENT_SELECT });
|
||||
if (!event) throw volunteerError('Event not found', 404);
|
||||
return event;
|
||||
}
|
||||
|
||||
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true };
|
||||
|
||||
const ROLE_INCLUDE = {
|
||||
session: true,
|
||||
kidsRoom: { select: { id: true, name: true } },
|
||||
slots: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { user: { select: USER_SELECT }, assignedBy: { select: { name: true } }, checkedInBy: { select: { name: true } } },
|
||||
},
|
||||
swaps: { where: { status: 'open' }, orderBy: { createdAt: 'asc' } },
|
||||
};
|
||||
|
||||
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
|
||||
|
||||
/**
|
||||
* A role for the API. `staff` adds the volunteers (names, contact, check-ins); others see only
|
||||
* how full it is, whether they're in it, and open cover requests.
|
||||
*/
|
||||
function roleView(role, event, { staff = false, userId = null, now = new Date() } = {}) {
|
||||
const shift = shiftOf(role, event);
|
||||
const sessions = sessionsUtil.activeSessions(event.sessions);
|
||||
const mine = userId ? role.slots.find(s => s.userId === userId) : null;
|
||||
const swapBySlot = Object.fromEntries(role.swaps.map(w => [w.slotId, w]));
|
||||
return {
|
||||
id: role.id,
|
||||
eventId: role.eventId,
|
||||
sessionId: role.sessionId,
|
||||
sessionLabel: role.session ? sessionsUtil.sessionLabel(role.session, sessions) : null,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
startsAt: role.startsAt,
|
||||
endsAt: role.endsAt,
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
kidsRoom: role.kidsRoom || null,
|
||||
order: role.order,
|
||||
past: new Date(shift.endsAt) < new Date(now),
|
||||
...fillOf(role.needed, role.slots.length),
|
||||
mine: mine ? { slotId: mine.id, checkedInAt: mine.checkedInAt, swapId: swapBySlot[mine.id]?.id || null } : null,
|
||||
coverNeeded: role.swaps.filter(w => w.fromUserId !== userId).map(w => ({ swapId: w.id, note: w.note || '' })),
|
||||
...(staff ? {
|
||||
volunteers: role.slots.map(s => ({
|
||||
slotId: s.id,
|
||||
userId: s.user.id,
|
||||
name: s.user.name,
|
||||
phone: s.user.phoneNumber || null,
|
||||
email: realEmail(s.user.email),
|
||||
assignedBy: s.assignedBy?.name || null,
|
||||
checkedInAt: s.checkedInAt,
|
||||
checkedInBy: s.checkedInBy?.name || null,
|
||||
checkInMethod: s.checkInMethod,
|
||||
swapOpen: !!swapBySlot[s.id],
|
||||
hasTicket: !!s.registrationId,
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An event's roster: its roles in order (by session, then shift start, then order). */
|
||||
async function roster(eventId, { staff = false, userId = null, now = new Date() } = {}, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const roles = await db.servingRole.findMany({ where: { eventId: event.id }, include: ROLE_INCLUDE });
|
||||
const views = roles.map(r => roleView(r, event, { staff, userId, now }))
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt) || a.order - b.order || a.name.localeCompare(b.name));
|
||||
const totals = views.reduce((t, r) => ({ needed: t.needed + r.needed, filled: t.filled + Math.min(r.filled, r.needed), checkedIn: t.checkedIn + (r.volunteers || []).filter(v => v.checkedInAt).length }), { needed: 0, filled: 0, checkedIn: 0 });
|
||||
return {
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, scheduleType: event.scheduleType, location: event.location, kidsCheckIn: event.kidsCheckIn },
|
||||
sessions: sessionsUtil.activeSessions(event.sessions).map(s => ({ id: s.id, label: sessionsUtil.sessionLabel(s, event.sessions), startsAt: s.startsAt, endsAt: s.endsAt })),
|
||||
roles: views,
|
||||
totals: { ...totals, fillRate: totals.needed ? Math.round((totals.filled / totals.needed) * 100) : null },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Roles (supervisors) ──────────────────────────────────────────────────────
|
||||
|
||||
function parseTime(v, what) {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) throw volunteerError(`Enter a valid ${what}.`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Check and clean a role from the request (`existing` when changing one). */
|
||||
async function cleanRole(event, input, existing = null, db = prisma) {
|
||||
const b = input || {};
|
||||
const data = {};
|
||||
if (b.name !== undefined || !existing) {
|
||||
data.name = cleanText(b.name, 60);
|
||||
if (!data.name) throw volunteerError('Enter a name for the role (e.g. Welcome, Parking, Sound).');
|
||||
}
|
||||
if (b.description !== undefined) data.description = cleanText(b.description, 500) || null;
|
||||
if (b.needed !== undefined || !existing) {
|
||||
const n = parseInt(b.needed ?? 1, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > MAX_NEEDED) throw volunteerError(`The number of people needed must be from 1 to ${MAX_NEEDED}.`);
|
||||
data.needed = n;
|
||||
}
|
||||
if (b.sessionId !== undefined || !existing) {
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const session = event.sessions.find(s => s.id === b.sessionId && !s.cancelledAt);
|
||||
if (!session) throw volunteerError('Choose the session this role is for.');
|
||||
data.sessionId = session.id;
|
||||
} else data.sessionId = null;
|
||||
}
|
||||
const startsAt = parseTime(b.startsAt, 'start time');
|
||||
const endsAt = parseTime(b.endsAt, 'end time');
|
||||
if (startsAt !== undefined) data.startsAt = startsAt;
|
||||
if (endsAt !== undefined) data.endsAt = endsAt;
|
||||
const shift = shiftOf({ ...existing, ...data, session: undefined }, event);
|
||||
if (shift.endsAt <= shift.startsAt) throw volunteerError('The shift has to end after it starts.');
|
||||
if (b.kidsRoomId !== undefined) {
|
||||
if (!b.kidsRoomId) data.kidsRoomId = null;
|
||||
else {
|
||||
const room = event.kidsRoomSetId ? await db.allocationGroup.findFirst({ where: { id: String(b.kidsRoomId), setId: event.kidsRoomSetId } }) : null;
|
||||
if (!room) throw volunteerError('Choose one of this event\'s kids rooms (set up under Kids check-in setup).');
|
||||
data.kidsRoomId = room.id;
|
||||
}
|
||||
}
|
||||
if (b.order !== undefined) data.order = Math.max(0, Math.min(1000, parseInt(b.order, 10) || 0));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createRole(eventId, input, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
if (await db.servingRole.count({ where: { eventId: event.id } }) >= MAX_ROLES) throw volunteerError(`An event can have up to ${MAX_ROLES} serving roles.`);
|
||||
const data = await cleanRole(event, input, null, db);
|
||||
if (input?.order === undefined) data.order = await db.servingRole.count({ where: { eventId: event.id, sessionId: data.sessionId } });
|
||||
return db.servingRole.create({ data: { ...data, eventId: event.id } });
|
||||
}
|
||||
|
||||
async function updateRole(roleId, input, db = prisma) {
|
||||
const existing = await db.servingRole.findUnique({ where: { id: String(roleId || '') } });
|
||||
if (!existing) throw volunteerError('Role not found', 404);
|
||||
const event = await loadEvent(existing.eventId, db);
|
||||
const data = await cleanRole(event, input, existing, db);
|
||||
if (data.sessionId !== undefined && data.sessionId !== existing.sessionId && await db.volunteerSlot.count({ where: { roleId: existing.id } })) {
|
||||
throw volunteerError('People have signed up for this role, so it can\'t move to another session — add a new role there instead.');
|
||||
}
|
||||
return db.servingRole.update({ where: { id: existing.id }, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role. Refused once someone has checked in for it (it counts for hours served).
|
||||
* Returns the volunteers who were on it, so they can be told.
|
||||
*/
|
||||
async function deleteRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { slots: { include: { user: { select: USER_SELECT } } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
if (role.slots.some(s => s.checkedInAt)) throw volunteerError('Someone has already checked in for this role, so it stays on the roster (it counts for hours served).');
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of role.slots) await releasePlace(tx, s);
|
||||
await tx.servingRole.delete({ where: { id: role.id } });
|
||||
});
|
||||
return { role, event, volunteers: role.slots.map(s => s.user) };
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The event's volunteer settings and the tickets that can be the volunteer ticket. */
|
||||
async function getSettings(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const [options, rooms] = await Promise.all([
|
||||
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
|
||||
event.kidsRoomSetId ? db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, select: { id: true, name: true }, orderBy: { order: 'asc' } }) : [],
|
||||
]);
|
||||
return {
|
||||
event: { id: event.id, title: event.title, volunteerOptionId: event.volunteerOptionId },
|
||||
options: options.filter(o => o.id !== event.kidsOptionId),
|
||||
kidsRooms: rooms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the free volunteer place on or off. `optionId: 'new'` adds a free "Volunteer" main ticket
|
||||
* (staff-only: it never shows publicly); null switches it off (places already given stay).
|
||||
*/
|
||||
async function saveSettings(eventId, { optionId }, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let next = null;
|
||||
if (optionId === 'new') {
|
||||
next = (await db.eventOption.create({ data: { eventId: event.id, name: 'Volunteer', price: 0, isMainTicket: true, sessionMode: 'all' } })).id;
|
||||
} else if (optionId) {
|
||||
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
|
||||
if (!opt || opt.id === event.kidsOptionId) throw volunteerError('Choose a main ticket of this event that covers the whole event (not the kids ticket).');
|
||||
next = opt.id;
|
||||
}
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerOptionId: next } });
|
||||
return getSettings(event.id, db);
|
||||
}
|
||||
|
||||
/** The event's check-in QR value (the token is made the first time). */
|
||||
async function checkInQr(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let token = event.volunteerCheckInToken;
|
||||
if (!token) {
|
||||
token = crypto.randomBytes(12).toString('hex');
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerCheckInToken: token } });
|
||||
}
|
||||
return { token, qr: QR_PREFIX + token };
|
||||
}
|
||||
|
||||
// ─── The free volunteer place ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Give a volunteer a free place on the event's volunteer ticket, when that's switched on and they
|
||||
* have no ticket for the event yet. Returns the registration id (shared by their slots), or null.
|
||||
*/
|
||||
async function givePlace(tx, event, userId) {
|
||||
if (!event.volunteerOptionId) return null;
|
||||
const shared = await tx.volunteerSlot.findFirst({ where: { userId, registrationId: { not: null }, role: { eventId: event.id }, registration: { status: { not: 'cancelled' } } }, select: { registrationId: true } });
|
||||
if (shared) return shared.registrationId;
|
||||
const hasTicket = await tx.ticket.count({ where: { eventId: event.id, OR: [{ userId }, { holderUserId: userId }], registrationOption: { registration: { status: { not: 'cancelled' } } } } });
|
||||
if (hasTicket) return null;
|
||||
const registration = await tx.registration.create({ data: { userId, eventId: event.id, status: 'paid' } });
|
||||
const line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.volunteerOptionId, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.ticket.create({ data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId, eventId: event.id, quantity: 1, emailSent: true } });
|
||||
return registration.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a slot goes (withdrawn, removed, swapped away): cancel its free place if no other slot of
|
||||
* the person at the event still uses it and the ticket was never scanned.
|
||||
*/
|
||||
async function releasePlace(tx, slot) {
|
||||
if (!slot.registrationId) return;
|
||||
const others = await tx.volunteerSlot.count({ where: { registrationId: slot.registrationId, id: { not: slot.id }, userId: slot.userId } });
|
||||
if (others) return;
|
||||
const scanned = await tx.ticketUsage.count({ where: { ticket: { registrationOption: { registrationId: slot.registrationId } } } });
|
||||
if (scanned) return;
|
||||
await tx.registration.updateMany({ where: { id: slot.registrationId, status: { not: 'cancelled' } }, data: { status: 'cancelled' } });
|
||||
}
|
||||
|
||||
// ─── Signing up ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { session: true, slots: { select: { id: true, userId: true } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
return role;
|
||||
}
|
||||
|
||||
/** Refuse a role whose shift is over, a cancelled session, or an inactive event. */
|
||||
function assertOpen(role, event, now = new Date()) {
|
||||
if (!event.isActive) throw volunteerError('This event isn\'t open any more.');
|
||||
if (role.session?.cancelledAt) throw volunteerError('That session was cancelled.');
|
||||
if (shiftOf(role, event).endsAt <= new Date(now)) throw volunteerError('This shift is already over.');
|
||||
}
|
||||
|
||||
/** Refuse when the person serves in another role at the same time. */
|
||||
async function assertFree(tx, event, role, userId) {
|
||||
const shift = shiftOf(role, event);
|
||||
const theirs = await tx.volunteerSlot.findMany({ where: { userId, roleId: { not: role.id }, role: { eventId: event.id } }, include: { role: { include: { session: true } } } });
|
||||
const clash = theirs.find(s => overlaps(shiftOf(s.role, event), shift));
|
||||
if (clash) throw volunteerError(`${clash.role.name} (${shiftText(shiftOf(clash.role, event))}) is at the same time.`, 409, 'SHIFT_CLASH');
|
||||
}
|
||||
|
||||
/**
|
||||
* Put someone on a role: they signed up (`assignedById` null — refused when the role is full), or
|
||||
* a supervisor assigned them (allowed beyond the number needed).
|
||||
*/
|
||||
async function signUp({ roleId, userId, assignedById = null, now = new Date() }, db = prisma) {
|
||||
const role = await loadRole(roleId, db);
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
assertOpen(role, event, now);
|
||||
if (role.slots.some(s => s.userId === userId)) throw volunteerError(assignedById ? 'This person is already on this role.' : 'You\'re already serving in this role.', 409);
|
||||
const slot = await db.$transaction(async tx => {
|
||||
if (!assignedById && await tx.volunteerSlot.count({ where: { roleId: role.id } }) >= role.needed) throw volunteerError('This role is full — thank you for offering!', 409, 'ROLE_FULL');
|
||||
await assertFree(tx, event, role, userId);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
return tx.volunteerSlot.create({ data: { roleId: role.id, userId, assignedById, registrationId } });
|
||||
});
|
||||
return { slot, role, event };
|
||||
}
|
||||
|
||||
async function loadSlot(slotId, db = prisma) {
|
||||
const slot = await db.volunteerSlot.findUnique({ where: { id: String(slotId || '') }, include: { user: { select: USER_SELECT }, role: { include: { session: true } } } });
|
||||
if (!slot) throw volunteerError('Not found', 404);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Take someone off a role (they withdrew, or a supervisor removed them). Not after check-in. */
|
||||
async function leave({ slotId, userId = null }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (userId && slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
if (slot.checkedInAt) throw volunteerError(`${userId ? 'You\'ve' : `${slot.user.name} has`} already checked in for this shift.`);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (userId && shiftOf(slot.role, event).endsAt <= new Date()) throw volunteerError('This shift is already over.');
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: slot.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
await releasePlace(tx, slot);
|
||||
await tx.volunteerSlot.delete({ where: { id: slot.id } });
|
||||
});
|
||||
return { slot, event };
|
||||
}
|
||||
|
||||
// ─── Swaps ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "Can't make it": ask for someone to take over the slot. One open request per slot. */
|
||||
async function requestSwap({ slotId, userId, note, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
if (slot.checkedInAt) throw volunteerError('You\'ve already checked in for this shift.');
|
||||
const open = await db.volunteerSwap.findFirst({ where: { slotId: slot.id, status: 'open' } });
|
||||
if (open) return { swap: open, slot, event, created: false };
|
||||
const swap = await db.volunteerSwap.create({ data: { slotId: slot.id, roleId: slot.roleId, fromUserId: userId, note: cleanText(note, 300) || null } });
|
||||
return { swap, slot, event, created: true };
|
||||
}
|
||||
|
||||
async function cancelSwap({ swapId, userId }, db = prisma) {
|
||||
const out = await db.volunteerSwap.updateMany({ where: { id: String(swapId || ''), fromUserId: userId, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
if (!out.count) throw volunteerError('Not found', 404);
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a slot someone can't make. Happens at once: the slot moves to the person accepting
|
||||
* (their reminder and free place are theirs now). Returns { swap, slot, from, to, event }.
|
||||
*/
|
||||
async function acceptSwap({ swapId, userId, now = new Date() }, db = prisma) {
|
||||
const swap = await db.volunteerSwap.findUnique({ where: { id: String(swapId || '') } });
|
||||
if (!swap || swap.status !== 'open' || !swap.slotId) throw volunteerError('Someone has already taken this place, or it isn\'t needed any more.', 409, 'SWAP_CLOSED');
|
||||
if (swap.fromUserId === userId) throw volunteerError('This is your own request — cancel it instead.');
|
||||
const slot = await loadSlot(swap.slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
await db.$transaction(async tx => {
|
||||
// Claim the request first, so two people accepting at once can't both take it.
|
||||
const claimed = await tx.volunteerSwap.updateMany({ where: { id: swap.id, status: 'open' }, data: { status: 'accepted', toUserId: userId, resolvedAt: new Date(now) } });
|
||||
if (!claimed.count) throw volunteerError('Someone has just taken this place.', 409, 'SWAP_CLOSED');
|
||||
if (await tx.volunteerSlot.count({ where: { roleId: slot.roleId, userId } })) throw volunteerError('You\'re already serving in this role.', 409);
|
||||
await assertFree(tx, event, slot.role, userId);
|
||||
await releasePlace(tx, slot);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { userId, assignedById: null, registrationId, remindedAt: null } });
|
||||
});
|
||||
const to = await db.user.findUnique({ where: { id: userId }, select: USER_SELECT });
|
||||
return { swap, slot, from: slot.user, to, event };
|
||||
}
|
||||
|
||||
/** Open cover requests the person could take (not their own, not a role they're in, not over). */
|
||||
async function openSwaps({ userId, now = new Date() }, db = prisma) {
|
||||
const swaps = await db.volunteerSwap.findMany({
|
||||
where: { status: 'open', fromUserId: { not: userId }, role: { event: { isActive: true, endDate: { gte: now } }, slots: { none: { userId } } } },
|
||||
include: { role: { include: { session: true, event: { select: EVENT_SELECT } } }, fromUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return swaps
|
||||
.map(w => ({ w, shift: shiftOf(w.role, w.role.event) }))
|
||||
.filter(({ shift }) => shift.endsAt > new Date(now))
|
||||
.map(({ w, shift }) => ({
|
||||
swapId: w.id, note: w.note || '', from: (w.fromUser?.name || '').split(' ')[0] || null,
|
||||
role: { id: w.role.id, name: w.role.name, sessionLabel: w.role.session ? sessionsUtil.sessionLabel(w.role.session, w.role.event.sessions) : null },
|
||||
event: { id: w.role.event.id, title: w.role.event.title },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift) },
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Check-in ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Put a checked-in volunteer of a kids-room role on duty in that room for the occasion. */
|
||||
async function startKidsDuty(tx, event, role, userId, now) {
|
||||
if (!role.kidsRoomId || !event.kidsCheckIn || !event.kidsRoomSetId) return false;
|
||||
const room = await tx.allocationGroup.findFirst({ where: { id: role.kidsRoomId, setId: event.kidsRoomSetId }, select: { id: true } });
|
||||
if (!room) return false;
|
||||
const occasion = kidsOccasion(role, now);
|
||||
const already = await tx.kidsRoomDuty.findFirst({ where: { userId, occasion, roomId: room.id, endedAt: null } });
|
||||
if (already) return true;
|
||||
// One room at a time, as with "I'm on duty".
|
||||
await tx.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
await tx.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId, fromRoster: true, startedAt: new Date(now) } });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function markCheckedIn(tx, event, slot, { by, method, now }) {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: new Date(now), checkedInById: by, checkInMethod: method } });
|
||||
return startKidsDuty(tx, event, slot.role, slot.userId, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* A volunteer scanned the event's check-in QR code: check in every slot of theirs whose shift is
|
||||
* on today. Returns { event, checkedIn: [{ role, kidsRoom }], already }.
|
||||
*/
|
||||
async function checkInWithQr({ qr, userId, now = new Date() }, db = prisma) {
|
||||
const token = String(qr || '').trim().replace(new RegExp(`^${QR_PREFIX}`, 'i'), '');
|
||||
const found = token ? await db.event.findUnique({ where: { volunteerCheckInToken: token }, select: { id: true } }) : null;
|
||||
if (!found) throw volunteerError('That isn\'t a volunteer check-in code from this site.', 404, 'BAD_QR');
|
||||
const event = await loadEvent(found.id, db);
|
||||
const slots = await db.volunteerSlot.findMany({ where: { userId, role: { eventId: event.id } }, include: { role: { include: { session: true, kidsRoom: { select: { name: true } } } } } });
|
||||
if (!slots.length) throw volunteerError(`You're not on the volunteer roster for ${event.title}.`, 404, 'NOT_ROSTERED');
|
||||
const today = slots.filter(s => !s.role.session?.cancelledAt && checkInOpen(shiftOf(s.role, event), now));
|
||||
if (!today.length) {
|
||||
const next = slots.map(s => shiftOf(s.role, event)).filter(sh => sh.endsAt > new Date(now)).sort((a, b) => a.startsAt - b.startsAt)[0];
|
||||
throw volunteerError(next ? `Check-in opens on the day of your shift (${shiftText(next)}).` : 'Your shifts at this event are over.', 400, 'NOT_TODAY');
|
||||
}
|
||||
const checkedIn = [];
|
||||
const already = [];
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of today) {
|
||||
if (s.checkedInAt) { already.push(s.role.name); continue; }
|
||||
const onDuty = await markCheckedIn(tx, event, s, { by: userId, method: 'qr', now });
|
||||
checkedIn.push({ role: s.role.name, kidsRoom: onDuty ? s.role.kidsRoom?.name || null : null });
|
||||
}
|
||||
});
|
||||
return { event: { id: event.id, title: event.title }, checkedIn, already };
|
||||
}
|
||||
|
||||
/** Staff tick a volunteer in (or undo it, which also ends their rostered kids-room duty). */
|
||||
async function checkInManual({ slotId, staffUserId, undo = false, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (undo) {
|
||||
if (!slot.checkedInAt) return { slot, changed: false };
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: null, checkedInById: null, checkInMethod: null } });
|
||||
if (slot.role.kidsRoomId) await tx.kidsRoomDuty.updateMany({ where: { userId: slot.userId, roomId: slot.role.kidsRoomId, fromRoster: true, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
});
|
||||
return { slot, changed: true };
|
||||
}
|
||||
if (slot.checkedInAt) return { slot, changed: false };
|
||||
if (!checkInOpen(shiftOf(slot.role, event), now) && new Date(now) < shiftOf(slot.role, event).startsAt) {
|
||||
throw volunteerError(`${slot.user.name} can be checked in on the day of the shift (${shiftText(shiftOf(slot.role, event))}).`);
|
||||
}
|
||||
await db.$transaction(tx => markCheckedIn(tx, event, slot, { by: staffUserId, method: 'manual', now }));
|
||||
return { slot, changed: true };
|
||||
}
|
||||
|
||||
/** For the kids board: who is rostered in each room for an occasion, and whether they've checked in. */
|
||||
async function rosteredForRooms(event, occasion, db = prisma) {
|
||||
if (!event.kidsRoomSetId) return {};
|
||||
const roles = await db.servingRole.findMany({
|
||||
where: { eventId: event.id, kidsRoom: { setId: event.kidsRoomSetId }, ...(event.scheduleType === 'sessions' ? { sessionId: occasion } : {}) },
|
||||
include: { session: true, slots: { include: { user: { select: { id: true, name: true } } } } },
|
||||
});
|
||||
const out = {};
|
||||
for (const r of roles) {
|
||||
// Single-date events: only roles whose shift is on that day.
|
||||
if (event.scheduleType !== 'sessions') {
|
||||
const shift = shiftOf(r, event);
|
||||
if (occasion < sessionsUtil.dayKey(shift.startsAt) || occasion > sessionsUtil.dayKey(shift.endsAt)) continue;
|
||||
}
|
||||
for (const s of r.slots) {
|
||||
(out[r.kidsRoomId] ||= []).push({ userId: s.user.id, name: s.user.name, checkedIn: !!s.checkedInAt });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── My serving ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The person's upcoming (and the last week's) slots, with open swap requests. */
|
||||
async function mySlots({ userId, now = new Date() }, db = prisma) {
|
||||
const since = new Date(new Date(now).getTime() - 7 * 24 * HOUR);
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId, role: { event: { endDate: { gte: since } } } },
|
||||
include: { role: { include: { session: true, kidsRoom: { select: { name: true } }, event: { select: EVENT_SELECT } } }, swaps: { where: { status: 'open' } } },
|
||||
});
|
||||
return slots
|
||||
.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return {
|
||||
slotId: s.id,
|
||||
role: { id: s.role.id, name: s.role.name, description: s.role.description || '', sessionLabel: s.role.session ? sessionsUtil.sessionLabel(s.role.session, s.role.event.sessions) : null, kidsRoom: s.role.kidsRoom?.name || null },
|
||||
event: { id: s.role.event.id, title: s.role.event.title, location: s.role.event.location },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
checkedInAt: s.checkedInAt,
|
||||
canCheckIn: !s.checkedInAt && checkInOpen(shift, now),
|
||||
past: shift.endsAt < new Date(now),
|
||||
swapId: s.swaps[0]?.id || null,
|
||||
hasTicket: !!s.registrationId,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt));
|
||||
}
|
||||
|
||||
/** Upcoming events with serving roles (for "Serve at an event"). */
|
||||
async function eventsWithRoles({ now = new Date(), where = {} } = {}, db = prisma) {
|
||||
return db.event.findMany({
|
||||
where: { AND: [{ isActive: true, endDate: { gte: now }, servingRoles: { some: {} } }, where] },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campusId: true, isHidden: true, _count: { select: { servingRoles: true } } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hours served per person over the events matching `eventWhere` (checked-in shifts only), most
|
||||
* hours first: [{ userId, name, shifts, hours, events }].
|
||||
*/
|
||||
async function hoursServed({ eventWhere = {}, from = null, to = null } = {}, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { checkedInAt: { not: null, ...(from ? { gte: new Date(from) } : {}), ...(to ? { lte: new Date(to) } : {}) }, role: { event: eventWhere } },
|
||||
include: { user: { select: { id: true, name: true } }, role: { include: { session: true, event: { select: { id: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
});
|
||||
const byUser = new Map();
|
||||
for (const s of slots) {
|
||||
const row = byUser.get(s.userId) || { userId: s.userId, name: s.user.name, shifts: 0, hours: 0, events: new Set() };
|
||||
row.shifts += 1;
|
||||
row.hours += shiftHours(shiftOf(s.role, s.role.event));
|
||||
row.events.add(s.role.event.id);
|
||||
byUser.set(s.userId, row);
|
||||
}
|
||||
return [...byUser.values()].map(r => ({ ...r, events: r.events.size })).sort((a, b) => b.hours - a.hours || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const servingUrl = () => `${frontendUrl()}/dashboard/user/serving`;
|
||||
|
||||
/** Send a text to a volunteer the way they like it (WhatsApp, email, or whichever works). Fire-and-forget. */
|
||||
function tell(user, { subject, heading, text }) {
|
||||
if (!user) return Promise.resolve([]);
|
||||
return require('./kids').messageParent(user, { subject, heading, text }).catch(() => []);
|
||||
}
|
||||
|
||||
/** Email the event's notify recipients (or its creator) about a roster change. */
|
||||
async function tellOrganisers(eventId, { subject, text }, db = prisma) {
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { createdBy: { select: { email: true } }, notifyRecipients: { select: { email: true } } },
|
||||
});
|
||||
const to = require('./notifications').getEventNotifyEmails(event);
|
||||
if (!to.length) return;
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({ to: to.join(','), subject, ...buildPrivacyEmail({ name: 'team', heading: subject, paragraphs: [text, `Roster: ${frontendUrl()}/dashboard/supervisor/volunteers?event=${eventId}`] }) }).catch(() => {});
|
||||
}
|
||||
|
||||
function placeText(role, event) {
|
||||
return `${role.name} at ${event.title}${role.session ? ` (${sessionsUtil.sessionLabel(role.session, event.sessions)})` : ''}, ${shiftText(shiftOf(role, event))}`;
|
||||
}
|
||||
|
||||
function signedUpMessages({ user, role, event, by = null }) {
|
||||
const text = by
|
||||
? `${by} has put you on the volunteer roster: ${placeText(role, event)}. Thank you for serving! If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`
|
||||
: `Thank you for volunteering: ${placeText(role, event)}. You'll get a reminder the day before. If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`;
|
||||
return tell(user, { subject: `Serving: ${role.name} — ${event.title}`, heading: 'You\'re on the roster', text });
|
||||
}
|
||||
|
||||
/** The cover request goes to the event's other volunteers (not the ones already in this role). */
|
||||
async function sendSwapRequest({ swap, slot, event }, db = prisma) {
|
||||
const others = await db.volunteerSlot.findMany({
|
||||
where: { role: { eventId: event.id }, userId: { not: slot.userId }, NOT: { roleId: slot.roleId } },
|
||||
distinct: ['userId'],
|
||||
include: { user: { select: USER_SELECT } },
|
||||
take: 200,
|
||||
});
|
||||
const inRole = new Set((await db.volunteerSlot.findMany({ where: { roleId: slot.roleId }, select: { userId: true } })).map(s => s.userId));
|
||||
const first = (slot.user.name || '').split(' ')[0] || 'A volunteer';
|
||||
const text = `${first} can't make it and is looking for someone to take over: ${placeText(slot.role, event)}.${swap.note ? ` "${swap.note}"` : ''} If you can, take it over in My account → Serving (${servingUrl()}) — the first to accept gets it.`;
|
||||
for (const o of others) {
|
||||
if (inRole.has(o.userId)) continue;
|
||||
await tell(o.user, { subject: `Cover needed: ${slot.role.name} — ${event.title}`, heading: 'Can you take over?', text });
|
||||
}
|
||||
return others.length;
|
||||
}
|
||||
|
||||
async function sendSwapAccepted({ slot, from, to, event }) {
|
||||
const what = placeText(slot.role, event);
|
||||
await tell(from, { subject: `${to.name} is taking over — ${event.title}`, heading: 'Your place is covered', text: `${to.name} has taken over ${what}. You're off the roster for it — thank you for finding cover.` });
|
||||
await tell(to, { subject: `Serving: ${slot.role.name} — ${event.title}`, heading: 'You\'re on the roster', text: `Thank you for taking over from ${from.name}: ${what}. You'll get a reminder the day before.` });
|
||||
await tellOrganisers(event.id, { subject: `Roster swap: ${slot.role.name} — ${event.title}`, text: `${to.name} has taken over from ${from.name}: ${what}.` });
|
||||
}
|
||||
|
||||
async function sendLeft({ slot, event, byStaff = null }) {
|
||||
const what = placeText(slot.role, event);
|
||||
if (byStaff) {
|
||||
await tell(slot.user, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `${byStaff} has taken you off the roster for ${what}. Thank you for being willing to serve.` });
|
||||
} else {
|
||||
await tellOrganisers(event.id, { subject: `Volunteer withdrew: ${slot.role.name} — ${event.title}`, text: `${slot.user.name} has withdrawn from ${what}.` });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRoleDeleted({ role, event, volunteers }) {
|
||||
for (const u of volunteers) {
|
||||
await tell(u, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `The ${role.name} role at ${event.title} is no longer needed, so you're off the roster for it. Thank you for being willing to serve.` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Reminders for shifts starting within the next 24 hours. Returns { sent }. */
|
||||
async function runReminderJob(now = new Date(), db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { remindedAt: null, checkedInAt: null, role: { event: { isActive: true, endDate: { gte: now }, startDate: { lte: new Date(new Date(now).getTime() + 31 * 24 * HOUR) } } } },
|
||||
include: { user: { select: USER_SELECT }, role: { include: { session: true, event: { select: EVENT_SELECT } } } },
|
||||
take: 2000,
|
||||
});
|
||||
let sent = 0;
|
||||
for (const s of slots) {
|
||||
if (s.role.session?.cancelledAt) continue;
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
if (!reminderDue(s, shift, now)) continue;
|
||||
// Claim it first so a second backend process doesn't send it again.
|
||||
const claimed = await db.volunteerSlot.updateMany({ where: { id: s.id, remindedAt: null }, data: { remindedAt: new Date(now) } });
|
||||
if (!claimed.count) continue;
|
||||
const ev = s.role.event;
|
||||
const place = ev.location ? ` at ${ev.location}` : '';
|
||||
await tell(s.user, {
|
||||
subject: `Reminder: serving ${s.role.name} — ${ev.title}`,
|
||||
heading: 'See you soon',
|
||||
text: `A reminder that you're serving: ${placeText(s.role, ev)}${place}. When you arrive, scan the volunteer check-in QR code with your phone. Can't make it? Ask for cover in My account → Serving (${servingUrl()}).`,
|
||||
});
|
||||
sent += 1;
|
||||
}
|
||||
return { sent };
|
||||
}
|
||||
|
||||
// ─── Privacy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** For "Download my data". */
|
||||
async function exportVolunteering(userId, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId },
|
||||
include: { role: { include: { session: true, event: { select: { title: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return slots.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return { event: s.role.event.title, role: s.role.name, from: shift.startsAt, to: shift.endsAt, signedUp: s.createdAt, assigned: !!s.assignedById, checkedIn: s.checkedInAt };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For anonymiseUser: shifts still to come are given up (open places, free tickets released);
|
||||
* past ones stay for the hours-served totals, under the anonymised account. Swap notes go.
|
||||
*/
|
||||
async function forgetVolunteer(tx, userId, now = new Date()) {
|
||||
const slots = await tx.volunteerSlot.findMany({ where: { userId, checkedInAt: null }, include: { role: { include: { session: true, event: { select: { startDate: true, endDate: true, sessions: true } } } } } });
|
||||
let removed = 0;
|
||||
for (const s of slots) {
|
||||
if (shiftOf(s.role, s.role.event).endsAt <= new Date(now)) continue;
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: s.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date(now) } });
|
||||
await releasePlace(tx, s);
|
||||
await tx.volunteerSlot.delete({ where: { id: s.id } });
|
||||
removed += 1;
|
||||
}
|
||||
await tx.volunteerSwap.updateMany({ where: { OR: [{ fromUserId: userId }, { toUserId: userId }], note: { not: null } }, data: { note: null } });
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QR_PREFIX, MAX_NEEDED, volunteerError,
|
||||
shiftOf, overlaps, shiftHours, fillOf, checkInOpen, reminderDue, kidsOccasion, reservedOptionIds, publicOptions, shiftText,
|
||||
loadEvent, roleView, roster, createRole, updateRole, deleteRole, getSettings, saveSettings, checkInQr,
|
||||
givePlace, releasePlace, signUp, leave, requestSwap, cancelSwap, acceptSwap, openSwaps,
|
||||
checkInWithQr, checkInManual, rosteredForRooms, mySlots, eventsWithRoles, hoursServed,
|
||||
signedUpMessages, sendSwapRequest, sendSwapAccepted, sendLeft, sendRoleDeleted, runReminderJob,
|
||||
exportVolunteering, forgetVolunteer,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
const { costCopies, setsToCopy } = require('../src/utils/eventClone');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('event cloning', () => {
|
||||
test('costs move to the new option with the same name, else to the whole event', () => {
|
||||
const costs = [
|
||||
{ id: 'c1', label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null, eventOption: null },
|
||||
{ id: 'c2', label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'old-a', eventOption: { name: 'Adult ' } },
|
||||
{ id: 'c3', label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: 'old-b', eventOption: { name: 'Leader' } },
|
||||
];
|
||||
const out = costCopies(costs, [{ id: 'new-a', name: 'adult' }, { id: 'new-c', name: 'Child' }]);
|
||||
expect(out).toEqual([
|
||||
{ label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null },
|
||||
{ label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'new-a' },
|
||||
{ label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: null },
|
||||
]);
|
||||
// Nothing of the source rows' identity is carried over.
|
||||
for (const c of out) expect(c.id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('kids rooms are not copied', () => {
|
||||
const sets = [{ id: 'rooms' }, { id: 'kids' }, { id: 'buses' }];
|
||||
expect(setsToCopy(sets, 'kids').map(s => s.id)).toEqual(['rooms', 'buses']);
|
||||
expect(setsToCopy(sets, null)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('copying setup is blocked in licence read-only mode', () => {
|
||||
expect(isBlocked('POST', '/api/events/abc/copy-from')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
||||
const { verifyToken, stateFor } = require('../src/hub/licence');
|
||||
const { incomeBand } = require('../src/hub/report');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
|
||||
const { compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices } = require('../src/controllers/hubController');
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
function sign(payload, key = privateKey) {
|
||||
@@ -100,6 +100,8 @@ describe('read-only mode', () => {
|
||||
['POST', '/api/payments/refund'],
|
||||
['POST', '/api/privacy/me/requests'],
|
||||
['POST', '/api/hub/connect'],
|
||||
['POST', '/api/hub/update-request/cancel'],
|
||||
['POST', '/api/hub/update-snooze'],
|
||||
['POST', '/api/users/login'],
|
||||
['POST', '/api/reports/pdf'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
@@ -146,8 +148,59 @@ test('version comparison for "update available"', () => {
|
||||
|
||||
test('an update request shows until the site runs the version asked for', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0' });
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0', outdated: false });
|
||||
expect(pendingUpdateRequest('1.42.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('a request for a version that has since been overtaken can be changed', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.0', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.0', outdated: true });
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.3', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.3', outdated: false });
|
||||
});
|
||||
|
||||
describe("what's new", () => {
|
||||
const releases = [
|
||||
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
|
||||
{ version: '1.48.0', notes: '- Webhooks', urgent: false, releasedAt: '2026-10-30T10:00:00.000Z' },
|
||||
{ version: '1.47.1', notes: '- Security fix', urgent: true, releasedAt: '2026-10-29T10:00:00.000Z' },
|
||||
{ version: 'bogus', notes: 'x' },
|
||||
];
|
||||
|
||||
test('only versions newer than this site, newest first', () => {
|
||||
expect(newerReleases(releases, '1.47.0').map(r => r.version)).toEqual(['1.48.0', '1.47.1']);
|
||||
expect(newerReleases(releases, '1.48.0')).toEqual([]);
|
||||
expect(newerReleases(null, '1.0.0')).toEqual([]);
|
||||
});
|
||||
|
||||
test('"Not now" holds until a newer version than the one put off', () => {
|
||||
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: '1.47.0', urgent: false })).toBe(true);
|
||||
expect(updateSnoozed({ latestVersion: '1.48.0', snoozedVersion: '1.47.0', urgent: false })).toBe(false);
|
||||
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: null, urgent: false })).toBe(false);
|
||||
});
|
||||
|
||||
test('urgent updates can\'t be put off', () => {
|
||||
expect(updateSnoozed({ latestVersion: '1.47.1', snoozedVersion: '1.47.1', urgent: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the provider's notices", () => {
|
||||
const NOW_N = new Date('2026-11-01T18:30:00Z');
|
||||
const n = (id, extra = {}) => ({ id, level: 'info', title: 'T', message: 'M', startsAt: null, endsAt: null, ...extra });
|
||||
|
||||
test('show between their start and end', () => {
|
||||
const notices = [
|
||||
n('now'),
|
||||
n('window', { startsAt: '2026-11-01T18:00:00Z', endsAt: '2026-11-01T20:00:00Z' }),
|
||||
n('later', { startsAt: '2026-11-02T18:00:00Z' }),
|
||||
n('ended', { endsAt: '2026-11-01T18:00:00Z' }),
|
||||
];
|
||||
expect(activeNotices(notices, NOW_N).map(x => x.id)).toEqual(['now', 'window']);
|
||||
});
|
||||
|
||||
test('an unknown level shows as information', () => {
|
||||
expect(activeNotices([n('x', { level: 'party' })], NOW_N)[0].level).toBe('info');
|
||||
expect(activeNotices([n('x', { level: 'maintenance' })], NOW_N)[0].level).toBe('maintenance');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Asking for an update again after a newer version came out: the request moves on to it — on the
|
||||
// Hub itself (1.5.0, `changed`), or by withdrawing and asking again with an older Hub.
|
||||
const mockSettings = new Map();
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: {
|
||||
findMany: jest.fn(async ({ where }) => [...mockSettings].filter(([key]) => where.key.in.includes(key)).map(([key, value]) => ({ key, value }))),
|
||||
upsert: jest.fn(async ({ where, create }) => { mockSettings.set(where.key, create.value); }),
|
||||
deleteMany: jest.fn(async ({ where }) => { mockSettings.delete(where.key); }),
|
||||
},
|
||||
}));
|
||||
jest.mock('../src/hub/report', () => ({ APP_VERSION: '1.46.2' }));
|
||||
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const hub = require('../src/hub/client');
|
||||
|
||||
const reply = (status, body) => ({ ok: status < 400, status, json: async () => body });
|
||||
|
||||
describe('changing an update request', () => {
|
||||
let calls;
|
||||
beforeEach(() => {
|
||||
mockSettings.clear();
|
||||
mockSettings.set('hub_install_id', 'install-1');
|
||||
mockSettings.set('hub_install_secret', encrypt('secret'));
|
||||
mockSettings.set('hub_update_requested_at', '2026-10-20T09:00:00.000Z');
|
||||
mockSettings.set('hub_update_requested_version', '1.47.0');
|
||||
calls = [];
|
||||
});
|
||||
afterEach(() => { delete global.fetch; });
|
||||
|
||||
test('Hub 1.5.0 moves the open request on to the latest version', async () => {
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: true, changed: true, previousVersion: '1.47.0' });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
expect(mockSettings.get('hub_update_requested_at')).toBe('2026-10-28T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('an older Hub (no `changed`): the old request is withdrawn and made again', async () => {
|
||||
let open = true;
|
||||
global.fetch = jest.fn(async url => {
|
||||
const path = new URL(url).pathname;
|
||||
calls.push(path);
|
||||
if (path.endsWith('/cancel')) { open = false; return reply(200, { cancelled: true }); }
|
||||
return open
|
||||
? reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true })
|
||||
: reply(201, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: false });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request', '/api/installs/update-request/cancel', '/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
});
|
||||
|
||||
test('asking again for the version already asked for changes nothing', async () => {
|
||||
mockSettings.set('hub_update_requested_version', '1.47.3');
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true });
|
||||
});
|
||||
await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
});
|
||||
});
|
||||
@@ -60,18 +60,23 @@ describe('cleanSections', () => {
|
||||
});
|
||||
|
||||
describe('noticePromptPending', () => {
|
||||
test('nobody is asked while no version needs re-acceptance', async () => {
|
||||
test('someone who never accepted any version is asked once (first)', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe('first');
|
||||
});
|
||||
|
||||
test('nobody else is asked while no version needs re-acceptance', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
test('asked when they accepted an older version than the newest one that needs it', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls.at(-1)[0].where).toEqual({ requiresAcceptance: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,3 +112,32 @@ describe('reportRowCount', () => {
|
||||
expect(reportRowCount(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes to the built-in text', () => {
|
||||
test('a site that never published shows the built-in text, so there is nothing to review', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a site with its own version is shown the changes it has not reviewed', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 2 });
|
||||
prisma.appSetting.findUnique.mockResolvedValue(null);
|
||||
expect((await notice.builtInUpdates()).map(u => u.revision)).toContain('1.48.1');
|
||||
prisma.appSetting.findUnique.mockResolvedValue({ value: notice.BUILT_IN_REVISION });
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('every changed section exists in the built-in text', () => {
|
||||
const ids = notice.defaultSections().map(s => s.id);
|
||||
for (const u of notice.BUILT_IN_UPDATES) for (const id of u.sections) expect(ids).toContain(id);
|
||||
expect(notice.revisionLess('1.47.9', '1.48.1')).toBe(true);
|
||||
expect(notice.revisionLess('1.48.10', '1.48.9')).toBe(false);
|
||||
});
|
||||
|
||||
test('the built-in text covers the features since 1.46', () => {
|
||||
const all = notice.defaultSections().map(s => s.body).join('\n');
|
||||
for (const words of ['volunteer', 'series you follow', 'season pass', 'webhook', 'cross_events_dismissed_notices', 'activate an account']) {
|
||||
expect([words, all.toLowerCase().includes(words.toLowerCase())]).toEqual([words, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
const mockDb = {
|
||||
payment: { findFirst: jest.fn(), create: jest.fn() },
|
||||
seasonPassPurchase: { findUnique: jest.fn(), update: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockStatus = jest.fn();
|
||||
const mockSaveTx = jest.fn();
|
||||
jest.mock('../src/payments/webhook', () => ({
|
||||
updateRegistrationStatus: (...a) => mockStatus(...a),
|
||||
saveProviderTransaction: (...a) => mockSaveTx(...a),
|
||||
}));
|
||||
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn().mockResolvedValue() }));
|
||||
|
||||
const pass = require('../src/utils/seasonPass');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('season passes', () => {
|
||||
describe('splitting the price', () => {
|
||||
test('in proportion to normal prices, in whole cents that add up', () => {
|
||||
// R400 pass: a R300 camp and two R50 breakfasts.
|
||||
expect(pass.splitCents(40000, [30000, 5000, 5000])).toEqual([30000, 5000, 5000]);
|
||||
// R100 over three equal events: the odd cent goes to the first.
|
||||
expect(pass.splitCents(10000, [5000, 5000, 5000])).toEqual([3334, 3333, 3333]);
|
||||
const parts = pass.splitCents(9999, [1234, 5678, 91]);
|
||||
expect(parts.reduce((s, x) => s + x, 0)).toBe(9999);
|
||||
});
|
||||
|
||||
test('free tickets everywhere: split equally', () => {
|
||||
expect(pass.splitCents(9000, [0, 0, 0])).toEqual([3000, 3000, 3000]);
|
||||
});
|
||||
|
||||
test('a variant\'s own price counts, else the ticket\'s', () => {
|
||||
const items = [
|
||||
{ eventOption: { price: 300 }, variant: { price: 200 } },
|
||||
{ eventOption: { price: 50 }, variant: { price: null } },
|
||||
{ eventOption: { price: 50 }, variant: null },
|
||||
];
|
||||
expect(pass.itemShares(240, items)).toEqual([160, 40, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a payment pays what each registration owes, in order; anything over goes to the last', () => {
|
||||
expect(pass.allocate([160, 40, 40], 240)).toEqual([160, 40, 40]);
|
||||
expect(pass.allocate([160, 40, 40], 100)).toEqual([100, 0, 0]);
|
||||
expect(pass.allocate([0, 40, 40], 90)).toEqual([0, 40, 50]);
|
||||
});
|
||||
|
||||
describe('when a pass is on sale', () => {
|
||||
const now = new Date('2027-01-10T08:00:00Z');
|
||||
const item = (start, extra = {}) => ({ event: { title: 'Breakfast', isActive: true, cashupStatus: 'open', startDate: new Date(start), endDate: new Date(start), scheduleType: 'single', sessions: [], ...extra } });
|
||||
const base = { isActive: true, saleEndsAt: null, items: [item('2027-02-01T07:00:00Z'), item('2027-03-01T07:00:00Z')] };
|
||||
|
||||
test('on sale before the first event starts', () => {
|
||||
expect(pass.notOnSale(base, now)).toBeNull();
|
||||
});
|
||||
test('stops when the first event starts, at the sale end, or switched off', () => {
|
||||
expect(pass.notOnSale({ ...base, items: [item('2027-01-09T07:00:00Z'), ...base.items] }, now)).toMatch(/first event started/);
|
||||
expect(pass.notOnSale({ ...base, saleEndsAt: new Date('2027-01-01') }, now)).toMatch(/no longer on sale/);
|
||||
expect(pass.notOnSale({ ...base, isActive: false }, now)).toMatch(/isn't on sale/);
|
||||
expect(pass.notOnSale({ ...base, items: [item('2027-02-01T07:00:00Z', { registrationDeadline: new Date('2027-01-05') })] }, now)).toMatch(/has closed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recording the payment', () => {
|
||||
const regs = [
|
||||
{ id: 'r1', eventId: 'camp', status: 'pending', createdAt: 1, registrationOptions: [{ quantity: 2, priceSnapshot: 160, tranches: [{ quantity: 2, priceSnapshot: 160 }] }], payments: [] },
|
||||
{ id: 'r2', eventId: 'b1', status: 'pending', createdAt: 2, registrationOptions: [{ quantity: 2, priceSnapshot: 40, tranches: [{ quantity: 2, priceSnapshot: 40 }] }], payments: [] },
|
||||
];
|
||||
const event = { transaction: { externalId: 'ext-1', amountCents: 40000, status: 'succeeded', methodType: 'card', metadata: { type: 'season_pass', purchaseId: 'pp1' } } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb.payment.findFirst.mockResolvedValue(null);
|
||||
mockDb.seasonPassPurchase.findUnique.mockResolvedValue({ id: 'pp1', userId: 'u1', registrations: regs });
|
||||
mockDb.payment.create.mockImplementation(({ data }) => Promise.resolve(data));
|
||||
mockStatus.mockResolvedValue({ status: 'paid' });
|
||||
mockSaveTx.mockResolvedValue();
|
||||
});
|
||||
|
||||
test('one payment per event for its share; only the first carries the provider id', async () => {
|
||||
const out = await pass.recordPassPayment('yoco', event, {});
|
||||
const made = mockDb.payment.create.mock.calls.map(c => c[0].data);
|
||||
expect(made.map(p => [p.registrationId, p.eventId, p.amount, p.externalId, p.seasonPassPurchaseId])).toEqual([
|
||||
['r1', 'camp', 320, 'ext-1', 'pp1'],
|
||||
['r2', 'b1', 80, null, 'pp1'],
|
||||
]);
|
||||
expect(made.every(p => p.isDonation === undefined && p.originalPaymentId === undefined)).toBe(true);
|
||||
expect(mockStatus).toHaveBeenCalledTimes(2);
|
||||
expect(mockDb.seasonPassPurchase.update).toHaveBeenCalledWith({ where: { id: 'pp1' }, data: { status: 'paid' } });
|
||||
expect(mockSaveTx.mock.calls[0][3]).toEqual({ reconciled: true, paymentId: made[0].id });
|
||||
expect(out.status).toBe('success');
|
||||
});
|
||||
|
||||
test('the same provider payment twice is recorded once', async () => {
|
||||
mockDb.payment.findFirst.mockResolvedValue({ id: 'already' });
|
||||
const out = await pass.recordPassPayment('yoco', event, {});
|
||||
expect(out).toMatchObject({ status: 'already_processed', paymentId: 'already' });
|
||||
expect(mockDb.payment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('a registration from a pass can\'t be edited or take a code', async () => {
|
||||
mockDb.registration.findUnique.mockResolvedValue({ seasonPassPurchaseId: 'pp1' });
|
||||
const res = { status: jest.fn(() => res), json: jest.fn() };
|
||||
const next = jest.fn();
|
||||
pass.notOnSeasonPass({ params: { id: 'r1' } }, res, next);
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('making, changing and buying passes is blocked in licence read-only mode', () => {
|
||||
expect(isBlocked('POST', '/api/season-passes')).toBe(true);
|
||||
expect(isBlocked('PUT', '/api/season-passes/p1')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/season-passes/p1/buy')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/season-passes/purchases/pp1/checkout')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), updateMany: jest.fn() },
|
||||
seriesFollow: { findMany: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockFilter = jest.fn();
|
||||
jest.mock('../src/utils/privacy', () => ({
|
||||
filterRecipients: (...a) => mockFilter(...a),
|
||||
bulkEmailFooter: () => ({ html: '<p>unsubscribe</p>', text: '\nunsubscribe', headers: { 'List-Unsubscribe': '<x>' } }),
|
||||
}));
|
||||
const mockSend = jest.fn();
|
||||
jest.mock('../src/utils/email', () => ({
|
||||
sendMail: (...a) => mockSend(...a),
|
||||
emailWrapper: body => `<html>${body}</html>`,
|
||||
ctaButton: (label, url) => `<a href="${url}">${label}</a>`,
|
||||
}));
|
||||
|
||||
const series = require('../src/utils/series');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
describe('event series', () => {
|
||||
test('a new series needs a name; the web address comes from it', () => {
|
||||
expect(() => series.cleanSeriesInput({ name: ' ' })).toThrow('name');
|
||||
expect(series.cleanSeriesInput({ name: "Men's Breakfast", description: ' Monthly ' })).toEqual({ name: "Men's Breakfast", slug: 'men-s-breakfast', description: 'Monthly' });
|
||||
expect(series.cleanSeriesInput({ name: 'Camp', slug: 'Family Camp!' }).slug).toBe('family-camp');
|
||||
});
|
||||
|
||||
test('changing a series only touches what was sent', () => {
|
||||
expect(series.cleanSeriesInput({ description: '' }, { name: 'Camp' })).toEqual({ description: null });
|
||||
});
|
||||
|
||||
test('upcoming soonest first, past latest first', () => {
|
||||
const now = new Date('2026-06-01T00:00:00Z');
|
||||
const ev = (id, start, end) => ({ id, startDate: new Date(start), endDate: new Date(end) });
|
||||
const { upcoming, past } = series.splitEvents([
|
||||
ev('2024', '2024-03-01', '2024-03-04'), ev('2027', '2027-03-01', '2027-03-04'),
|
||||
ev('2025', '2025-03-01', '2025-03-04'), ev('2026', '2026-05-30', '2026-06-02'),
|
||||
], now);
|
||||
expect(upcoming.map(e => e.id)).toEqual(['2026', '2027']);
|
||||
expect(past.map(e => e.id)).toEqual(['2025', '2024']);
|
||||
});
|
||||
|
||||
test('creating and changing series is blocked in licence read-only mode; following is not', () => {
|
||||
expect(isBlocked('POST', '/api/series')).toBe(true);
|
||||
expect(isBlocked('PUT', '/api/series/abc')).toBe(true);
|
||||
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
|
||||
});
|
||||
|
||||
describe('year-on-year comparison', () => {
|
||||
test('each event against the one before it', () => {
|
||||
const rows = series.withChanges([
|
||||
{ registrations: 40, tickets: 50, income: 10000, checkedIn: 45 },
|
||||
{ registrations: 50, tickets: 50, income: 9000, checkedIn: 0 },
|
||||
{ registrations: 25, tickets: 60, income: 9000, checkedIn: 30 },
|
||||
]);
|
||||
expect(rows[0].change).toBeNull();
|
||||
expect(rows[1].change).toEqual({ registrations: 25, tickets: 0, income: -10, checkedIn: -100 });
|
||||
// Nothing to compare with after a zero.
|
||||
expect(rows[2].change).toEqual({ registrations: -50, tickets: 20, income: 0, checkedIn: null });
|
||||
});
|
||||
|
||||
test('counts per event, check-ins averaged per session, only events in scope', async () => {
|
||||
const db = {
|
||||
eventSeries: { findUnique: jest.fn().mockResolvedValue({ id: 's1', name: 'Family Camp', slug: 'family-camp' }) },
|
||||
event: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'e25', title: 'Camp 2025', startDate: new Date('2025-03-01'), endDate: new Date('2025-03-04'), scheduleType: 'single', isActive: true, campus: null, _count: { sessions: 0 } },
|
||||
{ id: 'e26', title: 'Camp 2026', startDate: new Date('2026-03-01'), endDate: new Date('2026-03-04'), scheduleType: 'sessions', isActive: true, campus: null, _count: { sessions: 3 } },
|
||||
]),
|
||||
},
|
||||
registration: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _count: { _all: 40 } }, { eventId: 'e26', _count: { _all: 50 } }]) },
|
||||
ticket: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { quantity: 50 } }, { eventId: 'e26', _sum: { quantity: 60 } }]) },
|
||||
payment: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { amount: 10000 } }, { eventId: 'e26', _sum: { amount: 12000.456 } }]) },
|
||||
ticketUsage: { aggregate: jest.fn(({ where }) => Promise.resolve({ _sum: { quantityRedeemed: where.ticket.eventId === 'e25' ? 45 : 150 } })) },
|
||||
};
|
||||
const scopeWhere = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
|
||||
const out = await series.compareSeries('s1', { scopeWhere }, db);
|
||||
expect(db.event.findMany.mock.calls[0][0].where).toEqual({ AND: [{ seriesId: 's1' }, scopeWhere] });
|
||||
expect(out.events.map(e => [e.title, e.registrations, e.tickets, e.income, e.checkedIn, e.attendanceRate])).toEqual([
|
||||
['Camp 2025', 40, 50, 10000, 45, 90],
|
||||
['Camp 2026', 50, 60, 12000.46, 50, 83], // 150 check-ins over 3 sessions
|
||||
]);
|
||||
expect(out.events[1].change).toEqual({ registrations: 25, tickets: 20, income: 20, checkedIn: 11 });
|
||||
});
|
||||
|
||||
test('an unknown series is a 404', async () => {
|
||||
const db = { eventSeries: { findUnique: jest.fn().mockResolvedValue(null) } };
|
||||
await expect(series.compareSeries('nope', {}, db)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('announcing a new event to followers', () => {
|
||||
const now = new Date('2026-06-01T08:00:00Z');
|
||||
const event = { id: 'e1', title: 'Camp 2027', startDate: new Date('2027-03-01T08:00:00Z'), location: 'The farm', series: { id: 's1', name: 'Family Camp', slug: 'family-camp' } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb.event.findMany.mockResolvedValue([event]);
|
||||
mockDb.seriesFollow.findMany.mockResolvedValue([
|
||||
{ user: { id: 'u1', name: 'Thandi', email: 'thandi@test.local' } },
|
||||
{ user: { id: 'u2', name: 'No News', email: 'nonews@test.local' } },
|
||||
]);
|
||||
// Only Thandi opted in to news.
|
||||
mockFilter.mockImplementation(async list => ({ recipients: list.filter(r => r.userId === 'u1'), skipped: list.length - 1 }));
|
||||
mockSend.mockResolvedValue();
|
||||
});
|
||||
|
||||
test('only public events in a series that have not started and were not announced yet', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
|
||||
await series.runAnnounceJob(now);
|
||||
const where = mockDb.event.findMany.mock.calls[0][0].where;
|
||||
expect(where).toMatchObject({ isActive: true, isHidden: false, goLiveAt: { lte: now }, seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } });
|
||||
});
|
||||
|
||||
test('emails followers who want news, as news', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
|
||||
const out = await series.runAnnounceJob(now);
|
||||
expect(out).toEqual({ announced: 1, sent: 1, skipped: 1 });
|
||||
expect(mockFilter.mock.calls[0][1]).toEqual({ channel: 'email', kind: 'news' });
|
||||
const mail = mockSend.mock.calls[0][0];
|
||||
expect(mail.to).toBe('thandi@test.local');
|
||||
expect(mail.subject).toBe('New in Family Camp: Camp 2027');
|
||||
expect(mail.html).toContain('/events/e1');
|
||||
expect(mail.html).toContain('unsubscribe');
|
||||
expect(mail.headers['List-Unsubscribe']).toBeTruthy();
|
||||
});
|
||||
|
||||
test('an event another run already claimed is not sent again', async () => {
|
||||
mockDb.event.updateMany.mockResolvedValue({ count: 0 });
|
||||
const out = await series.runAnnounceJob(now);
|
||||
expect(out).toEqual({ announced: 0, sent: 0, skipped: 0 });
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
const v = require('../src/utils/volunteers');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
const event = {
|
||||
id: 'e1', startDate: new Date('2026-11-08T06:00:00Z'), endDate: new Date('2026-11-08T10:00:00Z'),
|
||||
sessions: [{ id: 's1', startsAt: new Date('2026-11-15T16:30:00Z'), endsAt: new Date('2026-11-15T18:30:00Z') }],
|
||||
};
|
||||
|
||||
describe('shifts', () => {
|
||||
test("a role's shift: its own times, else its session's, else the event's", () => {
|
||||
expect(v.shiftOf({}, event)).toEqual({ startsAt: event.startDate, endsAt: event.endDate });
|
||||
expect(v.shiftOf({ sessionId: 's1' }, event)).toEqual({ startsAt: event.sessions[0].startsAt, endsAt: event.sessions[0].endsAt });
|
||||
const own = { startsAt: '2026-11-08T05:30:00Z', endsAt: '2026-11-08T07:00:00Z' };
|
||||
expect(v.shiftOf(own, event)).toEqual({ startsAt: new Date(own.startsAt), endsAt: new Date(own.endsAt) });
|
||||
});
|
||||
|
||||
test('overlapping shifts; touching ends are fine', () => {
|
||||
const a = { startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:00:00Z' };
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(true);
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T08:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(false);
|
||||
expect(v.overlaps(a, { startsAt: '2026-11-08T05:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(false);
|
||||
});
|
||||
|
||||
test('hours to the nearest quarter hour', () => {
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:30:00Z' })).toBe(2.5);
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:50:00Z' })).toBe(0.75);
|
||||
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(0);
|
||||
});
|
||||
|
||||
test('fill', () => {
|
||||
expect(v.fillOf(4, 3)).toEqual({ needed: 4, filled: 3, open: 1, full: false });
|
||||
expect(v.fillOf(2, 3)).toEqual({ needed: 2, filled: 3, open: 0, full: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-in window', () => {
|
||||
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
|
||||
test('open on the day of the shift until it ends', () => {
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T04:00:00Z'))).toBe(true); // early, same day (SAST)
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T07:59:00Z'))).toBe(true);
|
||||
});
|
||||
test('closed the day before and after the shift ends', () => {
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-07T12:00:00Z'))).toBe(false);
|
||||
expect(v.checkInOpen(shift, new Date('2026-11-08T08:01:00Z'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminders', () => {
|
||||
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
|
||||
const signedUp = new Date('2026-11-01T10:00:00Z');
|
||||
test('due within 24 hours of the start, once', () => {
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(true);
|
||||
expect(v.reminderDue({ remindedAt: new Date(), createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(false);
|
||||
});
|
||||
test('not too early, not after the start', () => {
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T05:00:00Z'))).toBe(false);
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-08T06:00:00Z'))).toBe(false);
|
||||
});
|
||||
test('not for someone who signed up minutes ago', () => {
|
||||
const now = new Date('2026-11-07T20:00:00Z');
|
||||
expect(v.reminderDue({ remindedAt: null, createdAt: new Date(now.getTime() - 10 * 60 * 1000) }, shift, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kids rooms', () => {
|
||||
test('a session role counts for its session; otherwise for the day', () => {
|
||||
expect(v.kidsOccasion({ sessionId: 's1' })).toBe('s1');
|
||||
expect(v.kidsOccasion({ sessionId: null }, new Date('2026-11-08T06:00:00Z'))).toBe('2026-11-08');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved tickets', () => {
|
||||
test('the kids and volunteer tickets never show publicly', () => {
|
||||
const ev = { kidsOptionId: 'k', volunteerOptionId: 'vol', eventOptions: [{ id: 'adult' }, { id: 'k' }, { id: 'vol' }] };
|
||||
expect(v.reservedOptionIds(ev)).toEqual(['k', 'vol']);
|
||||
expect(v.publicOptions(ev).map(o => o.id)).toEqual(['adult']);
|
||||
expect(v.publicOptions({ eventOptions: [{ id: 'a' }] }).map(o => o.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only licence', () => {
|
||||
test.each([
|
||||
['POST', '/api/volunteers/roles/r1/sign-up'],
|
||||
['POST', '/api/volunteers/manage/events/e1/roles'],
|
||||
['POST', '/api/volunteers/manage/roles/r1/assign'],
|
||||
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
|
||||
test.each([
|
||||
['POST', '/api/volunteers/check-in'],
|
||||
['POST', '/api/volunteers/swaps/w1/accept'],
|
||||
['DELETE', '/api/volunteers/slots/s1'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
+127
-16
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.45.1** (and Hub 1.1.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.48.1** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,16 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
|
||||
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
|
||||
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
|
||||
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
|
||||
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
|
||||
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
|
||||
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
@@ -41,23 +51,25 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
## Next up
|
||||
|
||||
1. **Roll out the update requests:** deploy **Hub 1.1.0**, then set "Latest Cross Events release"
|
||||
to `1.42.0` in Hub → Settings, then update the sites to 1.42.0 (the last update done without a
|
||||
request). A 1.42.0 site talking to an older Hub shows "The Hub doesn't take update requests yet".
|
||||
2. **Try `scripts/install.sh` on a test server.** The production Hub key is built in since
|
||||
1.42.1, so a fresh install needs no `HUB_PUBLIC_KEY`; existing installs can drop it from `.env`.
|
||||
3. **1.46.0 — Volunteer rosters** (§5), per campus. Wallet passes (§7) are removed from scope
|
||||
(2026-10-28), so 1.47.0 is event series & cloning and 1.48.0 outbound webhooks.
|
||||
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.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
|
||||
and 1.51.0 accessibility.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
- **Release routine:** branch `feat/<name>` → commit → bump with
|
||||
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → write the church-facing
|
||||
`## X.Y.Z` section in **WHATS-NEW.md** (plain language; `— urgent` in the heading for a security
|
||||
or serious fix) → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
|
||||
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
|
||||
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
|
||||
@@ -100,9 +112,9 @@ encrypted settings unreadable or break existing installs.
|
||||
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
|
||||
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
|
||||
people only.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
- No CI: the Gitea workflow was removed (no machine to run a Docker runner). Run the
|
||||
backend tests, frontend eslint and e2e tests yourself before tagging, and deploy by
|
||||
hand with `scripts/deploy.sh <tag>` on the server.
|
||||
|
||||
## Where the multi-day code lives
|
||||
|
||||
@@ -157,7 +169,15 @@ encrypted settings unreadable or break existing installs.
|
||||
(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.
|
||||
@@ -193,8 +213,7 @@ encrypted settings unreadable or break existing installs.
|
||||
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (tried on a test server 2026-10-28).
|
||||
|
||||
## Where campuses live (1.43.0)
|
||||
|
||||
@@ -254,6 +273,94 @@ encrypted settings unreadable or break existing installs.
|
||||
- e2e: `kids.spec.ts` on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk
|
||||
checks in, parent sees the code, wrong code then the right one, room alert).
|
||||
|
||||
## Where volunteer rosters live (1.46.0)
|
||||
|
||||
- `backend/src/utils/volunteers.js` — everything: shifts (`shiftOf`: the role's own times, else its
|
||||
session's, else the event's), overlap and check-in-window rules, roles, sign-up / leave / swaps,
|
||||
the free volunteer place (`givePlace` / `releasePlace`, `VolunteerSlot.registrationId`), QR and
|
||||
manual check-in (kids-room roles start a `KidsRoomDuty` with `fromRoster`), `rosteredForRooms`
|
||||
(used by the kids board), messages, the `volunteers` reminder job, hours served, and the privacy
|
||||
parts (`exportVolunteering`, `forgetVolunteer`). Pure parts tested in `tests/volunteers.test.js`.
|
||||
- `reservedOptionIds` / `publicOptions` there: the kids and volunteer tickets are left out of public
|
||||
event pages and refused on public registration (`eventController`, `registrationController`).
|
||||
**A new staff-only ticket type should be added to `reservedOptionIds`.**
|
||||
- `controllers/volunteerController.js` + `routes/volunteerRoutes.js` (`/api/volunteers`); campus
|
||||
resolvers `servingRole` and `volunteerSlot`; sign-ups, new roles and assigning are in `readOnly.js`.
|
||||
- Swaps: no approval — accepting moves the slot at once (claimed with an `updateMany` on
|
||||
`status: 'open'` so two people can't both take it); `tellOrganisers` emails
|
||||
`getEventNotifyEmails(event)` (notify recipients, else the creator), not the registrations inbox.
|
||||
- The check-in QR holds `SERVE-<Event.volunteerCheckInToken>`; the page encodes it as a URL
|
||||
(`/serve/check-in?code=…`) so phone cameras open it directly.
|
||||
- Frontend: `lib/volunteers.ts`, `components/volunteers/{RosterSheet,CheckInQr}.tsx`, pages
|
||||
`dashboard/supervisor/volunteers` (roles, QR, ticket, hours), `dashboard/staff/volunteers` (the day),
|
||||
`dashboard/user/serving` (`?event=<id>` opens one event — the shareable sign-up link), `serve/check-in`.
|
||||
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
|
||||
sign-up, cover request, staff take over, QR check-in, roster).
|
||||
|
||||
## Where copying an event lives (1.47.0)
|
||||
|
||||
- The copy is made in the browser: `EventModal` in `app/dashboard/supervisor/events/page.tsx` takes
|
||||
`source` (create mode, `cloning`), fills its state from it (`cloneOptions` leaves out the kids and
|
||||
volunteer tickets and all ids; cancelled sessions dropped; sessions keyed by the source's ids so
|
||||
late joining still points right; sections loaded and mapped onto the copied options; feedback
|
||||
questions without the sent/answered state), and `moveAllDates` shifts every date for "New start".
|
||||
It saves through the normal create path, so **a new field on the event form is copied
|
||||
automatically** if it's initialised from `base`.
|
||||
- What the form doesn't hold is copied by the server afterwards: `utils/eventClone.js`
|
||||
(`copyEventSetup`: allocation sets/groups except `kidsRoomSetId`, costs for admins only since
|
||||
costs are admin-only) via `POST /api/events/:id/copy-from`. Tests: `tests/eventClone.test.js`,
|
||||
e2e "copy an event" in `supervisor.spec.ts`.
|
||||
|
||||
## Where event series live (1.47.1)
|
||||
|
||||
- `backend/src/utils/series.js` — input cleaning, `seriesForEvent`, `nextInSeries` (next public event
|
||||
starting later), `splitEvents`, follow/unfollow, the `series` job (`runAnnounceJob`: public, not
|
||||
started, `seriesAnnouncedAt` null → claimed, then followers filtered with `filterRecipients` as
|
||||
news), `exportFollows`. Tests: `tests/series.test.js`; e2e `series.spec.ts`.
|
||||
- `controllers/seriesController.js` + `routes/seriesRoutes.js` (`/api/series`, campus-independent:
|
||||
the list filters each series' events by scope). Event create/update take `seriesId`; changing it
|
||||
clears `seriesAnnouncedAt` so the new series' followers hear.
|
||||
- `SeriesFollow` is personal data: in `anonymiseUser` and `exportUserData`. No retention rule of its
|
||||
own — follows go when the account is anonymised.
|
||||
- Frontend: `app/dashboard/supervisor/series`, `app/series/[slug]`, `components/events/SeriesLinks.tsx`,
|
||||
`FollowSeriesButton.tsx` (asks the API itself: the page is cached for everyone), the Series box in
|
||||
the event editor. Series pages aren't in the sitemap yet (no public list endpoint).
|
||||
|
||||
## Where season passes live (1.47.3)
|
||||
|
||||
- `backend/src/utils/seasonPass.js` — `splitCents` / `itemShares` (the price over the events by
|
||||
normal price, whole cents), `notOnSale`, `passView`, `cleanPassInput`, `buyPass` (advisory lock per
|
||||
pass, stock checked per item, a registration per event with a tranche at the share),
|
||||
`startPassCheckout` (checkout metadata `{ type: 'season_pass', purchaseId }`), `recordPassPayment`
|
||||
(called from `payments/webhook.js` `recordSucceededPayment`; `allocate` pays each registration what
|
||||
it owes; first Payment carries `externalId`), `notOnSeasonPass` (route guard on options edit and
|
||||
coupons), `exportPurchases`. Tests: `tests/seasonPass.test.js`; e2e in `series.spec.ts`.
|
||||
- `refreshPricingForRegistration` skips pass registrations; `payments/status.js` treats a pass
|
||||
checkout as paid once its purchase is.
|
||||
- Frontend: `components/series/SeasonPassesPanel.tsx` (Event series page),
|
||||
`components/series/SeasonPassOffers.tsx` (series page: buy, "Your passes", Pay now).
|
||||
- Not built yet: buying a pass at the door or by staff, cancelling a whole purchase in one go
|
||||
(staff cancel and refund each event's registration), one combined payment email (each event's
|
||||
payment sends its own).
|
||||
|
||||
## Where outbound webhooks live (1.48.0)
|
||||
|
||||
- **Noticing:** `backend/src/config/db.js` wraps the Prisma client with `$extends` query hooks
|
||||
(registration create/update to paid or cancelled, payment create, ticketUsage create, user create,
|
||||
event create/update to public). **New code gets webhooks for free** as long as it writes through
|
||||
`prisma` (`updateMany` isn't hooked). Hooks only call `notice`; never put slow work there.
|
||||
- `backend/src/utils/webhooks.js` — event types, the in-memory queue (`notice` → `flush` after 1.5 s,
|
||||
which re-reads the record and skips it if it changed back), payload builders (kids/volunteer tickets
|
||||
left out), `send` (claim, SSRF check, sign, 10 s timeout, retry schedule), `runJob` (`webhooks` job:
|
||||
due retries, stuck sends, 30-day prune), `resend`, `sendTest`. Queued events are lost if the
|
||||
process stops in that 1.5 s (no outbox table yet).
|
||||
- `controllers/outboundWebhookController.js` + `routes/outboundWebhookRoutes.js`
|
||||
(`/api/outbound-webhooks`, admin). Operator register entry per endpoint (`operatorId`).
|
||||
- **Adding an event type:** add it to `EVENT_TYPES`, a case in `buildPayload`, a hook in `db.js` if
|
||||
needed, and a line in `dataShared` (operator register text).
|
||||
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
|
||||
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -275,3 +382,7 @@ encrypted settings unreadable or break existing installs.
|
||||
- Kids check-in: the offline desk is tested at the API level (sync, idempotency, code
|
||||
reservation), not in the browser. A family's two parents share one family; linking a second
|
||||
parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.
|
||||
- Volunteer rosters: if a volunteer edits their free volunteer registration on the edit page, the
|
||||
volunteer ticket isn't among the public options, so saving drops it (the backend only refuses
|
||||
adding or increasing it). Rare; the place is still in the roster, and a supervisor can give a
|
||||
ticket at the door. Reminders are a single "day before" one (no per-event timing yet).
|
||||
|
||||
+30
-12
@@ -32,7 +32,11 @@ per campus, public filter and campus pages, home campus, filters, campus broadca
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). **Next: 1.46.0 Volunteer rosters.**
|
||||
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. 1.48.0 **outbound webhooks** shipped. **Next: 1.49.0 Admin dashboard, command
|
||||
palette and bulk actions.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -443,7 +447,7 @@ Parents manage their family's children in My account and see pickup codes on the
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
Plan who serves where, for an event or each session. (Shipped in 1.46.0 — decisions below.)
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
@@ -458,15 +462,25 @@ Plan who serves where, for an event or each session.
|
||||
|
||||
## 6. Event series and cloning
|
||||
|
||||
- **Clone event:** copy an event with its options, variants, early-bird tiers,
|
||||
forms, consent text, feedback form, cost template, allocation groups and
|
||||
sessions (dates shifted to the new start). No registrations.
|
||||
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Four releases, one per part (2026-10-28): **1.47.0** clone, **1.47.1** series, **1.47.2**
|
||||
year-on-year reports, **1.47.3** season pass.
|
||||
|
||||
- **Clone event (1.47.0 ✅):** "Copy" opens the **New event popup filled in** from the source, so
|
||||
everything can be changed before saving: options, variants, early-bird tiers, form, consent
|
||||
text, feedback form, sections, notification recipients, certificates, reminders, allocation
|
||||
groups, cost template (admins) and sessions. A "New start" moves every date with it. Of the
|
||||
newer features only the **campus** is copied — not kids check-in or volunteer roles. No
|
||||
registrations, **no coupons**.
|
||||
- **Series (1.47.1 ✅):** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Breakfast) on one series page, with "Next in this series" and a series
|
||||
follow/notify option.
|
||||
- **Year-on-year reports:** registrations, income and attendance compared
|
||||
follow/notify option. **A series can span several campuses.** Following is **News &
|
||||
announcements** (opted-in only, unsubscribe link); followers get one email when a new event in the
|
||||
series goes live.
|
||||
- **Year-on-year reports (1.47.2 ✅):** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass:** a bundle across events in a series (extends event bundles).
|
||||
- **Season pass (1.47.3 ✅):** a bundle across events in a series. One checkout registers the buyer at
|
||||
every event on the pass; the supervisor picks the events (and ticket at each); the price is split
|
||||
in proportion to the events' normal prices for each event's cashup (2026-10-28).
|
||||
|
||||
---
|
||||
|
||||
@@ -487,7 +501,9 @@ Plan who serves where, for an event or each session.
|
||||
|
||||
## 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`,
|
||||
@@ -950,8 +966,8 @@ income justify the hardware:
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 – 1.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -1005,6 +1021,8 @@ feature.
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
|
||||
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
|
||||
|
||||
## Still open
|
||||
|
||||
+50
-4
@@ -55,7 +55,9 @@ POST /api/installs/register
|
||||
"installSecret": "…", // shown once; the install stores it encrypted
|
||||
"licence": "<licence token>",
|
||||
"heartbeatIntervalMinutes": 15,
|
||||
"latestVersion": "1.41.0"
|
||||
"latestVersion": "1.41.0",
|
||||
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
|
||||
"notices": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,9 +103,30 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||||
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
|
||||
}
|
||||
}
|
||||
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
|
||||
→ 200 {
|
||||
"licence": "<fresh licence token>",
|
||||
"latestVersion": "1.42.0",
|
||||
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
|
||||
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
|
||||
],
|
||||
"notices": [ // Hub 1.4.0+: the provider's banners for this install
|
||||
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
|
||||
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
|
||||
],
|
||||
"serverTime": "…"
|
||||
}
|
||||
```
|
||||
|
||||
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
|
||||
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
|
||||
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
|
||||
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
|
||||
admins put them off. The highest release is `latestVersion`.
|
||||
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
|
||||
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
|
||||
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
|
||||
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
|
||||
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
|
||||
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
|
||||
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
|
||||
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
|
||||
@@ -121,17 +144,40 @@ Signed exactly like the heartbeat.
|
||||
POST /api/installs/update-request
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1, "version": "1.41.2" }
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false }
|
||||
→ 200 { …, "alreadyRequested": true } // a request is already open: not emailed again
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false, "changed": false }
|
||||
→ 200 { …, "alreadyRequested": true, "changed": false } // a request for the latest is already open: not emailed again
|
||||
→ 200 { …, "alreadyRequested": true, "changed": true, "previousVersion": "1.42.0" }
|
||||
// the open request was for an older version: it now asks for the latest
|
||||
```
|
||||
|
||||
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
|
||||
alert address once. It is resolved when a heartbeat reports a newer version than before.
|
||||
- **Changing a request (Hub 1.5.0):** when a request is open for a version that has since been
|
||||
overtaken, asking again moves it on to the latest version — the alert says "latest is 1.47.3
|
||||
(changed from 1.47.0)" and the alert address is emailed once about the change. `version` is
|
||||
always the version now asked for. A Hub before 1.5.0 doesn't send `changed`; installs then
|
||||
withdraw the request and ask again (two emails, same result).
|
||||
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
|
||||
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
|
||||
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
|
||||
- No personal information: not who pressed the button (the Hub already has the admin contact).
|
||||
|
||||
### Withdrawing it
|
||||
|
||||
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
|
||||
|
||||
```
|
||||
POST /api/installs/update-request/cancel
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1 }
|
||||
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
|
||||
→ 200 { "cancelled": false } // there was no open request
|
||||
```
|
||||
|
||||
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
|
||||
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
|
||||
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
|
||||
|
||||
## 4. Licence token
|
||||
|
||||
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||||
|
||||
+2
-1
@@ -5,12 +5,13 @@ import path from "path";
|
||||
export type Role = "admin" | "supervisor" | "staff" | "user";
|
||||
export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string };
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string };
|
||||
campuses: Record<"north" | "south", { id: string; slug: string }>;
|
||||
northTicketQr: string;
|
||||
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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, loginAs, api, seed, API } from "./fixtures";
|
||||
|
||||
// Event series (1.47.1): a supervisor makes a series, events join it, the public series page
|
||||
// lists them, an event page links to the next one, a user follows it, and the series job tells
|
||||
// followers once when a new event goes live.
|
||||
type Created = { id: string; title: string };
|
||||
type Series = { id: string; slug: string; name: string };
|
||||
type JobRun = { queued: boolean; output: { announced: number; sent: number; skipped: number } };
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const event = (title: string, startsInDays: number, seriesId: string) => {
|
||||
const start = new Date(Date.now() + startsInDays * day);
|
||||
return { title, description: "A breakfast for the tests.", startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 60 * 60 * 1000).toISOString(), price: 0, location: "1 Test Street, Pretoria", seriesId };
|
||||
};
|
||||
|
||||
test.describe.serial("event series", () => {
|
||||
let series: Series;
|
||||
let past: Created;
|
||||
let next: Created;
|
||||
|
||||
test("a supervisor makes a series", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
await page.getByRole("button", { name: "+ New series" }).click();
|
||||
await page.getByLabel("Name").fill("E2E Breakfast");
|
||||
await page.getByLabel("Description").fill("Men's breakfast, once a month.");
|
||||
await page.getByRole("button", { name: "Create series" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("E2E Breakfast");
|
||||
await expect(page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" })).toContainText("/series/e2e-breakfast");
|
||||
|
||||
const all = await api<Series[]>(page, "supervisor", "GET", "/api/series");
|
||||
series = all.find((s) => s.name === "E2E Breakfast")!;
|
||||
past = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (last month)", -30, series.id));
|
||||
next = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (next month)", 30, series.id));
|
||||
// Both are live already; announce them now so only the event made after following counts below.
|
||||
await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
|
||||
});
|
||||
|
||||
test("the series page lists what's coming up and what's past", async ({ page }) => {
|
||||
await page.goto(`/series/${series.slug}`);
|
||||
await expect(page.getByRole("heading", { name: "E2E Breakfast", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("E2E Breakfast (next month)")).toBeVisible();
|
||||
await expect(page.getByTestId("series-past")).toContainText("E2E Breakfast (last month)");
|
||||
});
|
||||
|
||||
test("an event page links to its series and the next event in it", async ({ page }) => {
|
||||
await page.goto(`/events/${past.id}`);
|
||||
await expect(page.getByTestId("event-series")).toContainText("Part of E2E Breakfast");
|
||||
await page.getByTestId("next-in-series").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/events/${next.id}`));
|
||||
});
|
||||
|
||||
test("a user follows the series and hears once about a new event", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/series/${series.slug}`);
|
||||
await page.getByRole("button", { name: "Follow this series" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
|
||||
|
||||
await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (in two months)", 60, series.id));
|
||||
const first = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
|
||||
expect(first.output.announced).toBe(1);
|
||||
const again = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
|
||||
expect(again.output.announced).toBe(0);
|
||||
});
|
||||
|
||||
test("season pass: a supervisor sets one up, a user buys two, each event gets its share", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
const row = page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" });
|
||||
await row.getByRole("button", { name: "+ New pass" }).click();
|
||||
await row.getByLabel("Pass name").fill("E2E Breakfast pass");
|
||||
await row.getByLabel("Price (R)").fill("30");
|
||||
// The two upcoming breakfasts are ticked; both tickets are free normally, so the split is equal.
|
||||
await row.getByRole("button", { name: "Create pass" }).click();
|
||||
await expect(row.getByTestId("season-pass")).toContainText("E2E Breakfast pass");
|
||||
await expect(row.getByTestId("season-pass")).toContainText("share R 15.00");
|
||||
|
||||
// E2E has no online payment provider, so the places are booked and wait for payment.
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/series/${series.slug}`);
|
||||
const offer = page.getByTestId("season-pass-offer").filter({ hasText: "E2E Breakfast pass" });
|
||||
await offer.getByLabel("How many of E2E Breakfast pass").selectOption("2");
|
||||
await offer.getByRole("button", { name: "Buy · R 60.00" }).click();
|
||||
await expect(page.getByTestId("season-pass-offers").getByRole("status")).toContainText("Your places are booked");
|
||||
await expect(page.getByTestId("my-passes")).toContainText("2 × E2E Breakfast pass");
|
||||
await expect(page.getByTestId("my-passes").getByRole("button", { name: "Pay now" })).toBeVisible();
|
||||
|
||||
type Mine = { id: string; total: number; registrations: { id: string; event: { title: string } }[] }[];
|
||||
const [purchase] = await api<Mine>(page, "user", "GET", "/api/season-passes/mine");
|
||||
expect(purchase.total).toBe(60);
|
||||
expect(purchase.registrations.map((r) => r.event.title).sort()).toEqual(["E2E Breakfast (in two months)", "E2E Breakfast (next month)"]);
|
||||
// Each event's registration owes its share: 2 × R15.
|
||||
type Reg = { id: string; totalDueComputed?: number; registrationOptions: { quantity: number; priceSnapshot: number }[] };
|
||||
const regs = await api<Reg[]>(page, "supervisor", "GET", `/api/registrations/event/${next.id}`);
|
||||
const fromPass = regs.find((r) => r.id === purchase.registrations.find((x) => x.event.title === "E2E Breakfast (next month)")!.id)!;
|
||||
expect(fromPass.registrationOptions[0]).toMatchObject({ quantity: 2, priceSnapshot: 15 });
|
||||
|
||||
// A pass registration keeps its tickets as bought: no codes.
|
||||
const res = await page.request.post(`${API}/api/registrations/${fromPass.id}/coupon`, {
|
||||
headers: { Authorization: `Bearer ${seed().users.user.token}` }, data: { code: "ANY" },
|
||||
});
|
||||
expect(res.status()).toBe(409);
|
||||
});
|
||||
|
||||
test("year on year: the series report compares its events, oldest first", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
await page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" }).getByRole("link", { name: "Compare" }).click();
|
||||
const rows = page.getByTestId("comparison-row");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(rows.first()).toContainText("E2E Breakfast (last month)");
|
||||
await expect(rows.last()).toContainText("E2E Breakfast (in two months)");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,48 @@ test.describe("supervisor tools", () => {
|
||||
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("copy an event: the popup is filled in and every date moves with the new start", async ({ page }) => {
|
||||
type Full = EventRow & { eventOptions: { name: string; price: number }[]; sessions: { startsAt: string; label?: string | null }[] };
|
||||
const source = await api<Full>(page, "supervisor", "GET", `/api/events/${seed().events.course}`);
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/events");
|
||||
await page.locator("li", { hasText: "E2E Alpha Course" }).first().getByRole("button", { name: "Copy" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Alpha Course");
|
||||
await page.getByPlaceholder("Event title").fill("E2E Alpha Course (next term)");
|
||||
|
||||
// A week later: the start box holds the first session, so move it by seven days.
|
||||
const start = page.getByTestId("copy-panel").locator('input[type="datetime-local"]');
|
||||
const current = await start.inputValue();
|
||||
const later = new Date(new Date(current).getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
await start.fill(`${later.getFullYear()}-${pad(later.getMonth() + 1)}-${pad(later.getDate())}T${pad(later.getHours())}:${pad(later.getMinutes())}`);
|
||||
|
||||
const create = page.getByRole("button", { name: "Create Event" });
|
||||
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
|
||||
await page.getByRole("button", { name: "Next →" }).click();
|
||||
}
|
||||
await create.click();
|
||||
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeHidden();
|
||||
|
||||
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all?includePast=true");
|
||||
const row = all.find((e) => e.title === "E2E Alpha Course (next term)");
|
||||
expect(row).toBeTruthy();
|
||||
const copy = await api<Full>(page, "supervisor", "GET", `/api/events/${row!.id}`);
|
||||
expect(copy.scheduleType).toBe("sessions");
|
||||
const active = (s: Full) => s.sessions.filter((x) => !(x as { cancelled?: boolean }).cancelled);
|
||||
expect(active(copy)).toHaveLength(active(source).length);
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
// The form works in whole minutes (the seeded times have seconds).
|
||||
const minute = (iso: string) => Math.floor(new Date(iso).getTime() / 60000) * 60000;
|
||||
active(copy).forEach((s, i) => {
|
||||
expect(minute(s.startsAt) - minute(active(source)[i].startsAt)).toBe(week);
|
||||
expect(s.label ?? null).toBe(active(source)[i].label ?? null);
|
||||
});
|
||||
expect(copy.eventOptions.map((o) => [o.name, o.price]).sort()).toEqual(source.eventOptions.map((o) => [o.name, o.price]).sort());
|
||||
});
|
||||
|
||||
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
|
||||
await page.goto("/self-service");
|
||||
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
|
||||
|
||||
@@ -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'),
|
||||
@@ -88,6 +88,14 @@ async function seed(url) {
|
||||
price: 0, isHidden: true, createdById: users.supervisor.id,
|
||||
},
|
||||
});
|
||||
// Volunteer rosters (volunteers.spec.ts): on today, hidden.
|
||||
const serving = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Serving Sunday', description: 'Volunteer rosters for the tests.', startDate: new Date(Date.now() - 60 * 60 * 1000), endDate: new Date(Date.now() + 6 * 60 * 60 * 1000),
|
||||
price: 0, isHidden: true, createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
const paid = await prisma.event.create({
|
||||
data: {
|
||||
campusId: south.id,
|
||||
@@ -181,13 +189,18 @@ async function seed(url) {
|
||||
|
||||
const out = {
|
||||
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id },
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id },
|
||||
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
|
||||
northTicketQr: northTicket.qrCode,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, seed, loginAs, API } from "./fixtures";
|
||||
|
||||
// Volunteer rosters (1.46.0): a supervisor adds a role and switches the free volunteer ticket on,
|
||||
// a user signs up, can't make it and asks for cover, staff take the place over, then check in by
|
||||
// scanning the event's QR code. "E2E Serving Sunday" is seeded hidden and on today.
|
||||
test.describe.serial("volunteer rosters", () => {
|
||||
test("a supervisor adds a role and switches the volunteer ticket on", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto(`/dashboard/supervisor/volunteers?event=${seed().events.serving}`);
|
||||
await page.getByRole("button", { name: "Add a role" }).click();
|
||||
await page.getByLabel("Role name").fill("Welcome team");
|
||||
await page.getByLabel("People needed").fill("1");
|
||||
await page.getByRole("button", { name: "Add role" }).click();
|
||||
await expect(page.getByTestId("roster-role")).toContainText("Welcome team");
|
||||
await expect(page.getByTestId("roster-totals")).toContainText("0 of 1");
|
||||
|
||||
await page.getByRole("tab", { name: "Volunteer ticket" }).click();
|
||||
await page.getByRole("button", { name: /Add a free "Volunteer" ticket/ }).click();
|
||||
await expect(page.getByLabel("Volunteer ticket")).not.toHaveValue("");
|
||||
});
|
||||
|
||||
test("the volunteer ticket isn't offered publicly", async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/events/${seed().events.serving}`);
|
||||
const ev = await res.json();
|
||||
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Entry"]);
|
||||
});
|
||||
|
||||
test("a user signs up, then asks for cover", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/dashboard/user/serving?event=${seed().events.serving}`);
|
||||
await page.getByTestId("open-role").filter({ hasText: "Welcome team" }).getByRole("button", { name: "Sign up" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("you're on the roster");
|
||||
const shift = page.getByTestId("my-shift").filter({ hasText: "Welcome team" });
|
||||
await expect(shift).toContainText("free volunteer ticket");
|
||||
|
||||
page.once("dialog", d => d.accept("Away that weekend"));
|
||||
await shift.getByRole("button", { name: "Can't make it" }).click();
|
||||
await expect(shift).toContainText("Cover requested");
|
||||
});
|
||||
|
||||
test("staff take the place over, then check in with the QR code", async ({ page, request }) => {
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/user/serving");
|
||||
const cover = page.getByTestId("cover-request").filter({ hasText: "Welcome team" });
|
||||
await expect(cover).toContainText("Away that weekend");
|
||||
await cover.getByRole("button", { name: "Take over" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("taken over Welcome team");
|
||||
await expect(page.getByTestId("my-shift").filter({ hasText: "Welcome team" })).toBeVisible();
|
||||
|
||||
// The QR code on the wall holds this; a phone camera opens the check-in page.
|
||||
const qr = await request.get(`${API}/api/volunteers/manage/events/${seed().events.serving}/qr`, { headers: { Authorization: `Bearer ${seed().users.supervisor.token}` } });
|
||||
const { qr: code } = await qr.json();
|
||||
await page.goto(`/serve/check-in?code=${encodeURIComponent(code)}`);
|
||||
await expect(page.getByTestId("volunteer-checked-in")).toContainText("Welcome team");
|
||||
});
|
||||
|
||||
test("the roster shows who took over and checked in", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto(`/dashboard/supervisor/volunteers?event=${seed().events.serving}`);
|
||||
const volunteer = page.getByTestId("roster-volunteer");
|
||||
await expect(volunteer).toContainText(seed().users.staff.name);
|
||||
await expect(volunteer).toContainText("(QR)");
|
||||
await expect(page.getByTestId("roster-totals")).toContainText("1 checked in");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
import { test, expect, loginAs, API } from "./fixtures";
|
||||
|
||||
// Outbound webhooks (1.48.0): an admin adds an endpoint (a receiver this test runs on localhost),
|
||||
// sends a test, and a new account is delivered — signed with the endpoint's secret.
|
||||
type Received = { headers: http.IncomingHttpHeaders; body: string };
|
||||
const RECEIVER_PORT = 5199;
|
||||
|
||||
test.describe.serial("outbound webhooks", () => {
|
||||
const received: Received[] = [];
|
||||
let server: http.Server;
|
||||
let secret = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; });
|
||||
req.on("end", () => { received.push({ headers: req.headers, body }); res.writeHead(200).end("ok"); });
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(RECEIVER_PORT, resolve));
|
||||
});
|
||||
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
|
||||
|
||||
const signed = (r: Received) => {
|
||||
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(String(r.headers["x-crossevents-signature"]));
|
||||
return !!m && crypto.createHmac("sha256", secret).update(`${m[1]}.${r.body}`).digest("hex") === m[2];
|
||||
};
|
||||
|
||||
test("an admin adds a webhook and sends a test", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
await page.getByRole("button", { name: "+ Add webhook" }).click();
|
||||
await page.getByLabel("Name").fill("E2E receiver");
|
||||
await page.getByLabel("Address (https)").fill(`http://localhost:${RECEIVER_PORT}/hook`);
|
||||
await page.getByText("Account created", { exact: true }).click();
|
||||
await page.getByText("Registration made", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add webhook" }).click();
|
||||
|
||||
await expect(page.getByTestId("webhook-secret")).toBeVisible();
|
||||
secret = (await page.getByTestId("webhook-secret").locator("code").first().innerText()).trim();
|
||||
expect(secret).toMatch(/^whsec_/);
|
||||
await page.getByRole("button", { name: "I've saved it" }).click();
|
||||
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Send test" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("Test delivered (answered 200)");
|
||||
const testHook = received.find(r => r.headers["x-crossevents-event"] === "webhook.test");
|
||||
expect(testHook && signed(testHook)).toBe(true);
|
||||
});
|
||||
|
||||
test("a new account is sent, signed, and shows in the delivery log", async ({ page, request }) => {
|
||||
const email = `webhook-${Date.now()}@test.local`;
|
||||
const res = await request.post(`${API}/api/users`, { data: { name: "Webhook Person", email, password: "E2e-password-123", phoneNumber: "0820000123" } });
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
await expect.poll(() => received.filter(r => r.headers["x-crossevents-event"] === "user.created").length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const hook = received.find(r => r.headers["x-crossevents-event"] === "user.created")!;
|
||||
expect(signed(hook)).toBe(true);
|
||||
const body = JSON.parse(hook.body);
|
||||
expect(body).toMatchObject({ type: "user.created", data: { person: { name: "Webhook Person", email } } });
|
||||
expect(body.id).toMatch(/^evt_/);
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Delivery log" }).click();
|
||||
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -97,6 +97,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
breach_updated: "Security compromise updated",
|
||||
hub_connected: "Connected to the Cross Events Hub",
|
||||
hub_update_requested: "Asked the Hub for an update",
|
||||
hub_update_request_withdrawn: "Withdrew the update request",
|
||||
hub_update_snoozed: "Put off an update (Not now)",
|
||||
hub_update_unsnoozed: "Turned update reminders back on",
|
||||
campuses_toggled: "Campuses turned on/off",
|
||||
campus_created: "Campus added",
|
||||
campus_updated: "Campus changed",
|
||||
@@ -109,6 +112,15 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
kids_wrong_code: "Wrong pickup code entered",
|
||||
kids_custody_notes_changed: "Child's custody notes changed",
|
||||
kids_offline_sync: "Offline kids desk synced",
|
||||
volunteer_settings_updated: "Volunteer ticket changed",
|
||||
volunteer_roles_changed: "Serving role added, changed or deleted",
|
||||
volunteer_assigned: "Volunteer put on a roster",
|
||||
volunteer_removed: "Volunteer taken off a roster",
|
||||
volunteer_checked_in_manual: "Volunteer checked in by hand",
|
||||
series_created: "Event series created",
|
||||
series_updated: "Event series changed",
|
||||
series_deleted: "Event series deleted",
|
||||
season_pass_changed: "Season pass created, changed or deleted",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
|
||||
Baby,
|
||||
Baby, HandHeart, Layers,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -26,6 +26,7 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
|
||||
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
@@ -33,6 +34,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -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"} />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
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";
|
||||
|
||||
// The sidebar shows only on these exact routes — each role's dashboard root,
|
||||
// plus Profile & Security and Site Settings (matching the redesign
|
||||
@@ -89,6 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<Navbar />
|
||||
<LicenceBanner />
|
||||
<ProviderNotices />
|
||||
<PrivacyPrompts />
|
||||
<MobileSidebar />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
@@ -106,6 +108,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<LicenceBanner />
|
||||
<ProviderNotices />
|
||||
<PrivacyPrompts />
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby } from "lucide-react";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
@@ -17,6 +17,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/staff/volunteers", label: "Volunteers", description: "Who is serving today, check-ins and the check-in QR code", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import type { RosterEvent } from "@/lib/volunteers";
|
||||
|
||||
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
|
||||
export default function StaffVolunteersPage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<RosterEvent[] | null>(null);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const now = Date.now();
|
||||
const current = list.find(e => new Date(e.endDate).getTime() >= now);
|
||||
if (current) setEventId(current.id);
|
||||
})
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const event = events?.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteers</h1>
|
||||
<p className="text-sm text-gray-500">Who's serving today, and checking them in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
|
||||
{events && events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 items-end print:hidden">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{!eventId && <option value="">Choose an event…</option>}
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${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>}
|
||||
|
||||
<div className="mt-4">
|
||||
{eventId && (showQr
|
||||
? <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />
|
||||
: <RosterSheet token={token} eventId={eventId} canEdit={false} onMessage={setMsg} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,10 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useMyCampuses } from "@/hooks/useCampuses";
|
||||
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { Calendar, Copy } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
|
||||
import type { Event, EventAttachment, EventFormField, EventOption, EventSeries, Paginated, Section } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import {
|
||||
SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate,
|
||||
@@ -833,6 +833,7 @@ interface EventDraft {
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
location: string; allowNamedAttendees: boolean;
|
||||
campusId: string; // "" = all campuses
|
||||
seriesId: string; // "" = not in a series
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
@@ -849,6 +850,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
location, allowNamedAttendees: false,
|
||||
campusId: "",
|
||||
seriesId: "",
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
@@ -870,14 +872,49 @@ const blankOptions = (): OptionDraft[] => [
|
||||
interface EventModalProps {
|
||||
mode: "create" | "edit";
|
||||
event?: Event | null;
|
||||
// Create mode: start from a copy of this event (cloning).
|
||||
source?: Event | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
/** A source event's ticket options as drafts for a copy — no ids, and without the staff-only
|
||||
* kids and volunteer tickets (kids check-in and rosters aren't copied). `sourceIds` lines up
|
||||
* with `drafts`, to map the source's sections onto the copy. */
|
||||
function cloneOptions(source: Event): { drafts: OptionDraft[]; sourceIds: string[] } {
|
||||
const staffOnly = new Set([source.kidsOptionId, source.volunteerOptionId].filter(Boolean));
|
||||
const kept = (source.eventOptions || []).filter(o => !staffOnly.has(o.id));
|
||||
const tier = (t: { deadline: string; price: number; stockLimit?: number | null }): Tier => ({ deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") });
|
||||
return {
|
||||
sourceIds: kept.map(o => o.id),
|
||||
drafts: kept.map(o => ({
|
||||
name: o.name,
|
||||
price: String(o.price ?? ""),
|
||||
isMainTicket: !!o.isMainTicket,
|
||||
stockLimit: String(o.stockLimit ?? "0"),
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
sessionMode: o.sessionMode || "all",
|
||||
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => !t.variantId).map(tier),
|
||||
variants: (o.variants || []).map(v => ({
|
||||
name: v.name,
|
||||
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
|
||||
stockLimit: String(v.stockLimit ?? "0"),
|
||||
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
|
||||
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => t.variantId === v.id).map(tier),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const withoutIds = (fields: FieldDef[]): FieldDef[] => fields.map(f => ({ ...f, id: undefined }));
|
||||
|
||||
function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalProps) {
|
||||
const { token } = useAuth();
|
||||
const { settings } = useSiteSettings();
|
||||
const campusInfo = useMyCampuses();
|
||||
// Cloning fills "New event" from the source; everything can still be changed before saving.
|
||||
const cloning = mode === "create" && !!source;
|
||||
const base = mode === "edit" ? ev : source;
|
||||
// Campus-scoped: events for all campuses (and other campuses') are set up by admins and
|
||||
// supervisors for every campus — the server refuses changes, so the editor says so up front.
|
||||
const campusReadOnly = mode === "edit" && campusInfo.scoped && !campusInfo.campuses.some(c => c.id === ev?.campusId);
|
||||
@@ -888,26 +925,28 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
// ── event draft ──
|
||||
// New events default their location to the organisation's address (settings.org_address);
|
||||
// editing an existing event always reflects its own saved location instead.
|
||||
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
||||
title: ev.title || "", description: ev.description || "",
|
||||
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
||||
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
|
||||
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
|
||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||
requiresRegistration: ev.requiresRegistration !== false,
|
||||
allowNamedAttendees: !!ev.allowNamedAttendees,
|
||||
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
allowTicketTransfer: ev.allowTicketTransfer !== false,
|
||||
collectGender: !!ev.collectGender,
|
||||
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
campusId: ev.campusId || "",
|
||||
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
|
||||
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
|
||||
// editing an existing event always reflects its own saved location instead. A copy keeps the
|
||||
// source's, but not its short link (those are unique) or its go-live time.
|
||||
const [draft, setDraft] = useState<EventDraft>(() => base ? {
|
||||
title: base.title || "", description: base.description || "",
|
||||
startDate: toLocalDT(base.startDate), endDate: toLocalDT(base.endDate),
|
||||
registrationDeadline: toLocalDT(base.registrationDeadline), goLiveAt: cloning ? "" : toLocalDT(base.goLiveAt),
|
||||
price: String(base.price ?? ""), picture: base.picture || "", redirectUrl: cloning ? "" : base.redirectUrl || "",
|
||||
isActive: cloning || base.isActive !== false, isHidden: !!base.isHidden, requiresAuth: base.requiresAuth !== false,
|
||||
requiresRegistration: base.requiresRegistration !== false,
|
||||
allowNamedAttendees: !!base.allowNamedAttendees,
|
||||
requiresParentalConsent: !!base.requiresParentalConsent, consentText: base.consentText || "",
|
||||
consentCollectMedical: !!base.consentCollectMedical, consentCollectEmergency: !!base.consentCollectEmergency, consentCollectPhoto: !!base.consentCollectPhoto,
|
||||
waitlistEnabled: !!base.waitlistEnabled, waitlistOfferHours: String(base.waitlistOfferHours ?? 24), waitlistPartialOffers: base.waitlistPartialOffers !== false,
|
||||
allowTicketTransfer: base.allowTicketTransfer !== false,
|
||||
collectGender: !!base.collectGender,
|
||||
allowSponsorships: !!base.allowSponsorships, sponsorshipGoalSpots: base.sponsorshipGoalSpots ? String(base.sponsorshipGoalSpots) : "", showSponsorshipProgress: base.showSponsorshipProgress !== false,
|
||||
contactName: base.contactName || "", contactPhone: base.contactPhone || "", contactEmail: base.contactEmail || "",
|
||||
location: base.location || "",
|
||||
campusId: base.campusId || "",
|
||||
seriesId: base.seriesId || "",
|
||||
scheduleType: base.scheduleType === "sessions" ? "sessions" : "single",
|
||||
attendanceMode: base.attendanceMode === "pick" ? "pick" : "whole",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
|
||||
// A campus-scoped person's new event starts at their (first) campus — they can't make
|
||||
@@ -922,19 +961,30 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
});
|
||||
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
|
||||
|
||||
// Series to choose from (Basic Details).
|
||||
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => setSeriesList(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// ── multi-day schedule + attendance settings ──
|
||||
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
|
||||
// A copy leaves out cancelled sessions and saves the rest as new ones; each keeps the source
|
||||
// session's id as its key so "late joining until" still points at the same session.
|
||||
const [sessions, setSessions] = useState<KeyedSession[]>(() => cloning
|
||||
? (base?.sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).map(s => ({ ...toSessionDraft(s), id: undefined, hasCheckIns: false, key: s.id }))
|
||||
: (base?.sessions || []).map(s => keyed(toSessionDraft(s))));
|
||||
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
|
||||
lateJoinEnabled: !!ev?.lateJoinEnabled,
|
||||
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
|
||||
latePricing: ev?.latePricing || "full",
|
||||
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
|
||||
checkInWindow: ev?.checkInWindow || "whole_day",
|
||||
remindersEnabled: !!ev?.sessionReminders,
|
||||
reminderOffsets: ev?.sessionReminders?.offsets || [180],
|
||||
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
|
||||
certificatesEnabled: !!ev?.certificatesEnabled,
|
||||
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
|
||||
lateJoinEnabled: !!base?.lateJoinEnabled,
|
||||
lateJoinUntilKey: base?.lateJoinUntilSessionId || "",
|
||||
latePricing: base?.latePricing || "full",
|
||||
lateFixedPrice: base?.lateFixedPrice !== null && base?.lateFixedPrice !== undefined ? String(base.lateFixedPrice) : "",
|
||||
checkInWindow: base?.checkInWindow || "whole_day",
|
||||
remindersEnabled: !!base?.sessionReminders,
|
||||
reminderOffsets: base?.sessionReminders?.offsets || [180],
|
||||
reminderChannels: base?.sessionReminders?.channels || ["email", "whatsapp"],
|
||||
certificatesEnabled: !!base?.certificatesEnabled,
|
||||
certificate: { ...defaultCertificate(), ...(base?.certificateConfig || {}) },
|
||||
}));
|
||||
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
|
||||
const multi = draft.scheduleType === "sessions";
|
||||
@@ -950,7 +1000,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
|
||||
|
||||
// ── options (with per-variant tiers) ──
|
||||
const [cloned] = useState(() => (cloning && source ? cloneOptions(source) : null));
|
||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||
if (cloned) {
|
||||
if (!cloned.drafts.length) return blankOptions();
|
||||
return cloned.drafts.some(o => o.isMainTicket) ? cloned.drafts : cloned.drafts.map((o, i) => ({ ...o, isMainTicket: i === 0 }));
|
||||
}
|
||||
if (!ev?.eventOptions?.length) return blankOptions();
|
||||
return ev.eventOptions.map((o) => {
|
||||
const allTiers = o.earlyBirdTiers || [];
|
||||
@@ -982,15 +1037,32 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
// ── sections (staged for create mode) ──
|
||||
const [sectionDrafts, setSectionDrafts] = useState<SectionDraft[]>([]);
|
||||
// A copy takes the source's sections onto the copied options. A section whose options were all
|
||||
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
|
||||
useEffect(() => {
|
||||
if (!cloned || !source?.id || !token) return;
|
||||
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
|
||||
const drafts: SectionDraft[] = [];
|
||||
for (const sec of list || []) {
|
||||
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
|
||||
const optionIndices = ids.map(id => cloned.sourceIds.indexOf(id)).filter(i => i >= 0);
|
||||
if (ids.length && !optionIndices.length) continue;
|
||||
drafts.push({ name: sec.name, optionIndices });
|
||||
}
|
||||
setSectionDrafts(drafts);
|
||||
}).catch(() => {});
|
||||
}, [cloned, source?.id, token]);
|
||||
|
||||
// ── attachments (staged for create mode; uploaded after event is created) ──
|
||||
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
|
||||
|
||||
// ── notify recipients (staged for create mode; saved after event is created) ──
|
||||
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>([]);
|
||||
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>(() => (cloning ? source?.notifyRecipients || [] : []));
|
||||
|
||||
// ── form ──
|
||||
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [formDef, setFormDef] = useState<EventFormDef>(() => (cloning && source?.form
|
||||
? { isRequired: !!source.form.isRequired, fields: withoutIds(toFieldDefs(source.form.fields)) }
|
||||
: { isRequired: false, fields: [] }));
|
||||
const formLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
|
||||
@@ -1005,16 +1077,26 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const [fbDef, setFbDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [fbSettings, setFbSettings] = useState({ sendAt: "", audience: "checked_in", anonymous: false, intro: "" });
|
||||
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
|
||||
// How far a copy's dates have been moved so far, for the source's feedback date if it arrives
|
||||
// after the move.
|
||||
const shiftedRef = useRef(0);
|
||||
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || !token) return;
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
if (!feedbackFrom || !token) return;
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
if (cloning) {
|
||||
// The questions and settings only — not whether it was sent or who answered.
|
||||
setFbDef({ isRequired: false, fields: withoutIds(toFieldDefs(f.fields)) });
|
||||
setFbSettings({ sendAt: f.sendAt ? toLocalDT(new Date(new Date(f.sendAt).getTime() + shiftedRef.current)) : "", audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
|
||||
return;
|
||||
}
|
||||
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
|
||||
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
|
||||
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
|
||||
}).catch(() => {});
|
||||
}, [mode, ev?.id, token]);
|
||||
}, [feedbackFrom, cloning, token]);
|
||||
const enableFeedback = (on: boolean) => {
|
||||
setFbEnabled(on);
|
||||
if (on && fbDef.fields.length === 0) {
|
||||
@@ -1050,9 +1132,24 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
|
||||
|
||||
// ── copy: move every date by the same amount (sessions, deadline, early birds, feedback) ──
|
||||
const copyStart = multi ? scheduleBounds?.start || "" : draft.startDate;
|
||||
const moveAllDates = (newStart: string) => {
|
||||
const ms = newStart && copyStart ? new Date(newStart).getTime() - new Date(copyStart).getTime() : NaN;
|
||||
if (!ms || isNaN(ms)) return;
|
||||
shiftedRef.current += ms;
|
||||
const sh = (v: string) => (v ? toLocalDT(new Date(new Date(v).getTime() + ms)) : v);
|
||||
const shTiers = (tiers: Tier[]) => tiers.map(t => ({ ...t, deadline: sh(t.deadline) }));
|
||||
setDraft(d => ({ ...d, startDate: sh(d.startDate), endDate: sh(d.endDate), registrationDeadline: sh(d.registrationDeadline), goLiveAt: sh(d.goLiveAt) }));
|
||||
setSessions(list => list.map(s => ({ ...s, startsAt: sh(s.startsAt), endsAt: sh(s.endsAt) })));
|
||||
setOptions(list => list.map(o => ({ ...o, earlyBirdTiers: shTiers(o.earlyBirdTiers), variants: o.variants.map(v => ({ ...v, earlyBirdTiers: shTiers(v.earlyBirdTiers) })) })));
|
||||
setFbSettings(s => ({ ...s, sendAt: sh(s.sendAt) }));
|
||||
};
|
||||
|
||||
// ── auto-fill first option's price from the event base price (create mode only, until the
|
||||
// user directly edits that option's price — then it becomes independent) ──
|
||||
const [mainPriceTouched, setMainPriceTouched] = useState(false);
|
||||
// user directly edits that option's price — then it becomes independent). A copy keeps its
|
||||
// own option prices. ──
|
||||
const [mainPriceTouched, setMainPriceTouched] = useState(cloning);
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || mainPriceTouched) return;
|
||||
setOptions(prev => {
|
||||
@@ -1307,6 +1404,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
location: draft.location.trim(),
|
||||
// Only sent while campuses are on; the server ignores it otherwise.
|
||||
...(campusInfo.enabled ? { campusId: draft.campusId || null } : {}),
|
||||
seriesId: draft.seriesId || null,
|
||||
};
|
||||
|
||||
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
|
||||
@@ -1323,10 +1421,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (formDef.isRequired || formDef.fields.length > 0) {
|
||||
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
|
||||
}
|
||||
if (cloning) body.clonedFromId = source!.id;
|
||||
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
if (created?.id) {
|
||||
await saveOptions(created.id);
|
||||
await saveFeedback(created.id);
|
||||
// Groups (and planned costs, for admins) aren't in this form; the server copies them.
|
||||
if (cloning) {
|
||||
await apiFetch(`/api/events/${created.id}/copy-from`, { method: "POST", authToken: token || undefined, body: { fromEventId: source!.id } });
|
||||
}
|
||||
// Fetch event to get final option IDs for sections
|
||||
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
|
||||
const createdOptions = fullEvent?.eventOptions || [];
|
||||
@@ -1399,14 +1502,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b">
|
||||
<h2 className="text-lg font-semibold">{mode === "create" ? "New Event" : "Edit Event"}</h2>
|
||||
<h2 className="text-lg font-semibold">{cloning ? "Copy Event" : mode === "create" ? "New Event" : "Edit Event"}</h2>
|
||||
<button type="button" className="text-gray-400 hover:text-gray-700 text-xl leading-none" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Step bar */}
|
||||
<div className="flex items-center gap-1 px-5 py-3 border-b overflow-x-auto">
|
||||
{STEPS.map((s, i) => {
|
||||
const clickable = mode === "edit";
|
||||
const clickable = mode === "edit" || cloning;
|
||||
return (
|
||||
<button key={i} type="button"
|
||||
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
|
||||
@@ -1462,6 +1565,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{/* ── Step 0: Basic Details ── */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-3">
|
||||
{cloning && (
|
||||
<div className="p-3 border rounded bg-brand-50 border-brand-200 text-sm space-y-2" data-testid="copy-panel">
|
||||
<p className="text-gray-700">
|
||||
A copy of <span className="font-medium">{source?.title}</span> — check each step before saving. Registrations, coupons,
|
||||
attachments, kids check-in and volunteer roles aren't copied.
|
||||
</p>
|
||||
<DTInput label="New start" value={copyStart} onChange={moveAllDates}
|
||||
hint={`Moves every date with it: the end${multi ? ", the sessions" : ""}, registration deadline, early-bird deadlines and feedback date.`} />
|
||||
{copyStart && new Date(copyStart) < new Date() && <p className="text-xs text-amber-700">This copy still starts in the past.</p>}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Title <span className="text-red-500">*</span></label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.title} onChange={e => upd({ title: e.target.value })} placeholder="Event title" />
|
||||
@@ -1519,11 +1633,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{campusInfo.scoped ? <option value="" disabled>Choose your campus…</option> : <option value="">All campuses</option>}
|
||||
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
{/* An archived campus stays on events that already have it. */}
|
||||
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
|
||||
{base?.campus && !campusInfo.campuses.some(c => c.id === base.campus!.id) && <option value={base.campus.id}>{base.campus.name} (archived)</option>}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">"All campuses" events show at every campus — for church-wide conferences and camps.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-series">Series</label>
|
||||
<select id="event-series" className="w-full border rounded px-3 py-2 text-sm bg-white" value={draft.seriesId} onChange={e => upd({ seriesId: e.target.value })}>
|
||||
<option value="">Not in a series</option>
|
||||
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">Groups separate events, like every year's camp, on one page with "Next in this series". <a href="/dashboard/supervisor/series" target="_blank" className="underline">Manage series</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||
@@ -1882,7 +2004,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
// ─── event card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
|
||||
function EventCard({ ev, onEdit, onCopy }: { ev: Event; onEdit: () => void; onCopy: () => void }) {
|
||||
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
|
||||
const isInactive = ev.isActive === false;
|
||||
const isClosed = ev.cashupStatus === "closed";
|
||||
@@ -1896,13 +2018,20 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
|
||||
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
|
||||
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
|
||||
<CampusBadge campus={ev.campus} />
|
||||
{ev.series && <span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">{ev.series.name}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
|
||||
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit →</span>
|
||||
<div className="flex items-center gap-3 shrink-0 mt-0.5">
|
||||
<button type="button" title="Make a new event from this one" className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-brand-700"
|
||||
onClick={e => { e.stopPropagation(); onCopy(); }}>
|
||||
<Copy className="w-3.5 h-3.5" /> Copy
|
||||
</button>
|
||||
<span className="text-xs text-brand-600">Edit →</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1970,6 +2099,19 @@ export default function ManageEventsPage() {
|
||||
} catch { setEditingEvent(ev); }
|
||||
setModalMode("edit");
|
||||
};
|
||||
// A copy needs the whole source event (options, tiers, form, recipients).
|
||||
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
|
||||
const openCopy = async (ev: Event) => {
|
||||
try {
|
||||
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
setEditingEvent(null);
|
||||
setCopyFrom(full);
|
||||
setModalMode("create");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the event to copy");
|
||||
}
|
||||
};
|
||||
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
@@ -2010,13 +2152,13 @@ export default function ManageEventsPage() {
|
||||
<div className="border rounded-xl bg-white shadow-sm p-4">
|
||||
{filtered.length === 0
|
||||
? <p className="text-sm text-gray-500">{events.length === 0 ? 'No events yet. Click "+ Add Event" to create one.' : "No events match the current filters."}</p>
|
||||
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} />)}</ul>
|
||||
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} onCopy={() => openCopy(ev)} />)}</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
{modalMode && (
|
||||
<EventModal mode={modalMode} event={editingEvent}
|
||||
onClose={() => { setModalMode(null); setEditingEvent(null); }}
|
||||
<EventModal mode={modalMode} event={editingEvent} source={copyFrom}
|
||||
onClose={closeModal}
|
||||
onSuccess={loadEvents} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
|
||||
Baby,
|
||||
Baby, HandHeart, Layers,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -26,10 +26,12 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Layers, ExternalLink, Users, BarChart3 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { SeasonPassesPanel } from "@/components/series/SeasonPassesPanel";
|
||||
import type { EventSeries } from "@/types";
|
||||
|
||||
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
|
||||
const blank: Draft = { name: "", slug: "", description: "", picture: "" };
|
||||
|
||||
function SeriesForm({ draft, onChange, onSave, onCancel, saving }: { draft: Draft; onChange: (d: Draft) => void; onSave: () => void; onCancel: () => void; saving: boolean }) {
|
||||
const upd = (patch: Partial<Draft>) => onChange({ ...draft, ...patch });
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-gray-50 space-y-3" data-testid="series-form">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-name">Name</label>
|
||||
<input id="series-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => upd({ name: e.target.value })} placeholder="e.g. Family Camp" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-slug">Web address</label>
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-400 mr-1">/series/</span>
|
||||
<input id="series-slug" className="w-full border rounded px-3 py-2 text-sm" value={draft.slug} onChange={e => upd({ slug: e.target.value })} placeholder="made from the name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-description">Description</label>
|
||||
<textarea id="series-description" rows={3} className="w-full border rounded px-3 py-2 text-sm" value={draft.description} onChange={e => upd({ description: e.target.value })} placeholder="Shown at the top of the series page" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-picture">Picture (web address, optional)</label>
|
||||
<input id="series-picture" className="w-full border rounded px-3 py-2 text-sm" value={draft.picture} onChange={e => upd({ picture: e.target.value })} placeholder="https://…" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={onSave} disabled={saving || !draft.name.trim()} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{saving ? "Saving…" : draft.id ? "Save series" : "Create series"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeriesPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const canView = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [list, setList] = useState<EventSeries[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setList(await apiFetch<EventSeries[]>("/api/series", { authToken: token }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the series");
|
||||
}
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
setSaving(true); setError(null); setNotice(null);
|
||||
try {
|
||||
const body = { name: draft.name, slug: draft.slug || undefined, description: draft.description, picture: draft.picture };
|
||||
await apiFetch(draft.id ? `/api/series/${draft.id}` : "/api/series", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
|
||||
setNotice(draft.id ? "Series saved." : `"${draft.name.trim()}" created — choose it in the event editor to add events.`);
|
||||
setDraft(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't save the series");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (s: EventSeries) => {
|
||||
if (!window.confirm(`Delete "${s.name}"? Its ${s.eventCount} event${s.eventCount === 1 ? "" : "s"} stay, just without a series, and ${s.followers} follower${s.followers === 1 ? "" : "s"} won't hear about new ones.`)) return;
|
||||
setError(null); setNotice(null);
|
||||
try {
|
||||
await apiFetch(`/api/series/${s.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
setNotice(`"${s.name}" deleted.`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't delete the series");
|
||||
}
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Layers className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event series</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Group events like every year's camp on one page</p>
|
||||
</div>
|
||||
</div>
|
||||
{!draft && (
|
||||
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={() => { setDraft({ ...blank }); setNotice(null); }}>+ New series</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canView && !loading && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access.</div>}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
{notice && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm" role="status">{notice}</div>}
|
||||
|
||||
{draft && !draft.id && (
|
||||
<div className="mb-4"><SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} /></div>
|
||||
)}
|
||||
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 border rounded-xl bg-white p-4">No series yet. A series groups separate events — Camp 2025, Camp 2026 — on one page, with "Next in this series" and followers.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{list.map(s => (
|
||||
<li key={s.id} className="border rounded-xl bg-white shadow-sm p-4" data-testid="series-row">
|
||||
{draft?.id === s.id ? (
|
||||
<SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900">{s.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-3 flex-wrap">
|
||||
<Link href={`/series/${s.slug}`} target="_blank" className="inline-flex items-center gap-1 text-brand-700 hover:underline">/series/{s.slug} <ExternalLink className="w-3 h-3" /></Link>
|
||||
<span className="inline-flex items-center gap-1"><Users className="w-3 h-3" /> {s.followers} follower{s.followers === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
{s.description && <p className="text-sm text-gray-600 mt-1 whitespace-pre-line">{s.description}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{s.events.length > 0 && (
|
||||
<Link href={`/dashboard/supervisor/reports?report=seriesComparison&series=${s.id}`} className="inline-flex items-center gap-1 text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50">
|
||||
<BarChart3 className="w-3.5 h-3.5" /> Compare
|
||||
</Link>
|
||||
)}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50"
|
||||
onClick={() => { setDraft({ id: s.id, name: s.name, slug: s.slug, description: s.description || "", picture: s.picture || "" }); setNotice(null); }}>Edit</button>
|
||||
{isAdmin && <button type="button" className="text-xs px-3 py-1.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(s)}>Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{s.events.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No events yet — choose this series in the event editor (Basic details).</p>
|
||||
) : (
|
||||
<ul className="divide-y border rounded-lg">
|
||||
{s.events.map(e => (
|
||||
<li key={e.id} className="px-3 py-2 flex items-center justify-between gap-2 text-sm flex-wrap">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{e.title}</span>
|
||||
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Inactive</span>}
|
||||
{e.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
|
||||
{new Date(e.endDate).getTime() < now && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
|
||||
<CampusBadge campus={e.campus} />
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{new Date(e.startDate).toLocaleDateString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{s.eventCount > s.events.length && (
|
||||
<p className="text-[11px] text-gray-400 mt-1">{s.eventCount - s.events.length} more at other campuses.</p>
|
||||
)}
|
||||
</div>
|
||||
{s.events.length > 0 && <SeasonPassesPanel seriesId={s.id} seriesSlug={s.slug} />}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
|
||||
|
||||
type Settings = {
|
||||
event: { id: string; title: string; volunteerOptionId: string | null };
|
||||
options: { id: string; name: string; price: number }[];
|
||||
kidsRooms: { id: string; name: string }[];
|
||||
};
|
||||
type Tab = "roster" | "qr" | "ticket" | "hours";
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "roster", label: "Roster" },
|
||||
{ id: "qr", label: "Check-in QR" },
|
||||
{ id: "ticket", label: "Volunteer ticket" },
|
||||
{ id: "hours", label: "Hours served" },
|
||||
];
|
||||
|
||||
/** Supervisor → Volunteer rosters: serving roles per event or session, people, check-in QR, volunteer ticket, hours. */
|
||||
export default function VolunteerRostersPage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<RosterEvent[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("roster");
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [hours, setHours] = useState<HoursRow[] | null>(null);
|
||||
const [hoursAll, setHoursAll] = useState(true);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const wanted = new URLSearchParams(window.location.search).get("event");
|
||||
if (wanted && list.some(e => e.id === wanted)) setEventId(wanted);
|
||||
})
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (!token || !eventId) { setSettings(null); return; }
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
|
||||
}
|
||||
}, [token, eventId, setMsg]);
|
||||
useEffect(() => { loadSettings(); }, [loadSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || tab !== "hours") return;
|
||||
setHours(null);
|
||||
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
|
||||
.then(setHours)
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
|
||||
}, [token, tab, hoursAll, eventId, setMsg]);
|
||||
|
||||
const saveTicket = async (optionId: string | null, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
|
||||
setMsg({ type: "ok", text: ok });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const event = events.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteer rosters</h1>
|
||||
<p className="text-sm text-gray-500">Who serves where, for an event or each session</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md print:hidden">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${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>}
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-4 border-b print:hidden" role="tablist">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)} className={`px-3 py-2 text-sm -mb-px border-b-2 ${tab === t.id ? "border-brand-600 text-brand-700 font-medium" : "border-transparent text-gray-500"}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{tab !== "hours" && !eventId && <p className="text-sm text-gray-500">Choose an event.</p>}
|
||||
{tab === "roster" && eventId && (
|
||||
<p className="text-xs text-gray-500 mb-3 print:hidden">
|
||||
Sign-up link to share: <span className="font-mono select-all break-all" data-testid="signup-link">{`${typeof window !== "undefined" ? window.location.origin : ""}/dashboard/user/serving?event=${eventId}`}</span>
|
||||
</p>
|
||||
)}
|
||||
{tab === "roster" && eventId && <RosterSheet token={token} eventId={eventId} canEdit kidsRooms={settings?.kidsRooms || []} onMessage={setMsg} />}
|
||||
{tab === "qr" && eventId && <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />}
|
||||
{tab === "ticket" && eventId && settings && (
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
|
||||
<h2 className="font-semibold">Free volunteer ticket</h2>
|
||||
<p className="text-sm text-gray-600">Off unless you choose a ticket. When it's on, everyone on this event's roster gets a free place on it (shown in their My Events), unless they already have a ticket. It's never offered to the public, and it's given back when they leave the roster.</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select aria-label="Volunteer ticket" className="border rounded-lg px-3 py-2 text-sm" value={settings.event.volunteerOptionId || ""} disabled={busy}
|
||||
onChange={e => saveTicket(e.target.value || null, e.target.value ? "Volunteers get a free place on this ticket." : "No free volunteer ticket.")}>
|
||||
<option value="">Off — no free ticket</option>
|
||||
{settings.options.map(o => <option key={o.id} value={o.id}>{o.name}{o.price ? ` (R${o.price})` : " (free)"}</option>)}
|
||||
</select>
|
||||
{!settings.event.volunteerOptionId && <button disabled={busy} onClick={() => saveTicket("new", "Added a free \"Volunteer\" ticket — volunteers get a place on it.")} className="px-3 py-2 rounded-lg border text-sm">Add a free "Volunteer" ticket</button>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Places already given stay when you switch it off.</p>
|
||||
</section>
|
||||
)}
|
||||
{tab === "hours" && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="inline-flex items-center gap-1"><input type="radio" checked={hoursAll} onChange={() => setHoursAll(true)} /> All events</label>
|
||||
<label className="inline-flex items-center gap-1"><input type="radio" checked={!hoursAll} disabled={!eventId} onChange={() => setHoursAll(false)} /> This event only</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Shifts people checked in for, counted at the shift's length.</p>
|
||||
{!hours && <div className="text-sm text-gray-500">Loading…</div>}
|
||||
{hours && hours.length === 0 && <p className="text-sm text-gray-500">No checked-in shifts yet.</p>}
|
||||
{hours && hours.length > 0 && (
|
||||
<table className="w-full text-sm border rounded-xl overflow-hidden bg-white">
|
||||
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">Volunteer</th><th className="p-2">Shifts</th><th className="p-2">Events</th><th className="p-2">Hours</th></tr></thead>
|
||||
<tbody>{hours.map(h => <tr key={h.userId} className="border-t"><td className="p-2">{h.name}</td><td className="p-2">{h.shifts}</td><td className="p-2">{h.events}</td><td className="p-2 font-medium">{hoursText(h.hours)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
|
||||
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
|
||||
|
||||
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50";
|
||||
|
||||
/** My account → Serving: my shifts, cover requests I could take, and events looking for volunteers. */
|
||||
export default function ServingPage() {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<Mine | null>(null);
|
||||
const [msg, setMsg] = useDismissingState<Msg>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [openEvent, setOpenEvent] = useState<string>("");
|
||||
const [roster, setRoster] = useState<Roster | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
|
||||
}
|
||||
}, [token, setMsg]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// ?event=<id>: the sign-up link a supervisor shares (works for hidden events too).
|
||||
useEffect(() => {
|
||||
const wanted = new URLSearchParams(window.location.search).get("event");
|
||||
if (wanted) setOpenEvent(wanted);
|
||||
}, []);
|
||||
|
||||
const loadRoster = useCallback(async (id: string) => {
|
||||
if (!token || !id) { setRoster(null); return; }
|
||||
try {
|
||||
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
|
||||
}
|
||||
}, [token, setMsg]);
|
||||
useEffect(() => { loadRoster(openEvent); }, [openEvent, loadRoster]);
|
||||
|
||||
const call = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
setMsg({ type: "ok", text: ok });
|
||||
await Promise.all([load(), openEvent ? loadRoster(openEvent) : null]);
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const askCover = (s: MySlot) => {
|
||||
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
|
||||
if (note === null) return;
|
||||
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
|
||||
};
|
||||
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
|
||||
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
|
||||
|
||||
const upcoming = (data?.slots || []).filter(s => !s.past);
|
||||
const past = (data?.slots || []).filter(s => s.past);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Serving</h1>
|
||||
<p className="text-sm text-gray-500">Your volunteer shifts, and events looking for people to serve</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" 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>}
|
||||
{!data && <div className="text-sm text-gray-500">Loading…</div>}
|
||||
|
||||
{data && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">My shifts</h2>
|
||||
{upcoming.length === 0 && <p className="text-sm text-gray-500">You're not on any roster yet. Sign up for a role below.</p>}
|
||||
{upcoming.map(s => (
|
||||
<div key={s.slotId} className="border rounded-xl p-4 bg-white shadow-sm" data-testid="my-shift">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">{s.role.name} <span className="font-normal text-gray-500">· {s.event.title}</span></div>
|
||||
<div className="text-sm text-gray-600">{s.role.sessionLabel ? `${s.role.sessionLabel} · ` : ""}{s.shift.text}{s.event.location ? ` · ${s.event.location}` : ""}</div>
|
||||
{s.role.kidsRoom && <div className="text-xs text-gray-500">Kids room: {s.role.kidsRoom}</div>}
|
||||
{s.role.description && <div className="text-xs text-gray-500 mt-1">{s.role.description}</div>}
|
||||
{s.hasTicket && <div className="text-xs text-gray-500 mt-1">Your free volunteer ticket is in My Events.</div>}
|
||||
</div>
|
||||
{s.checkedInAt
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> Checked in</span>
|
||||
: s.swapId && <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">Cover requested</span>}
|
||||
</div>
|
||||
{s.canCheckIn && <p className="mt-2 text-sm text-brand-700 flex items-center gap-1"><QrCode className="w-4 h-4" /> When you arrive, scan the volunteer check-in QR code with your phone's camera.</p>}
|
||||
{!s.checkedInAt && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{s.swapId
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
|
||||
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can't make it</button>}
|
||||
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{past.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500">Last week ({past.length})</summary>
|
||||
<ul className="mt-1 space-y-0.5 text-gray-600">
|
||||
{past.map(s => <li key={s.slotId}>{s.role.name} · {s.event.title} · {s.shift.text}{s.checkedInAt ? " · served" : ""}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data && data.cover.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Can you take over?</h2>
|
||||
{data.cover.map(c => (
|
||||
<div key={c.swapId} className="border border-amber-300 rounded-xl p-4 bg-amber-50 flex flex-wrap items-center justify-between gap-3" data-testid="cover-request">
|
||||
<div>
|
||||
<div className="font-semibold">{c.role.name} <span className="font-normal text-gray-600">· {c.event.title}</span></div>
|
||||
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
|
||||
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can't make it{c.note ? `: “${c.note}”` : ""}</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Serve at an event</h2>
|
||||
{data.events.length === 0 && !openEvent && <p className="text-sm text-gray-500">No events are looking for volunteers right now.</p>}
|
||||
{(data.events.length > 0 || openEvent) && (
|
||||
<label className="text-sm block max-w-md">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={openEvent} onChange={e => setOpenEvent(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{roster && !data.events.some(e => e.id === roster.event.id) && <option value={roster.event.id}>{roster.event.title} · {formatDate(new Date(roster.event.startDate))}</option>}
|
||||
{data.events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))} ({e.roles} role{e.roles === 1 ? "" : "s"})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{roster && roster.event.id === openEvent && (
|
||||
<div className="space-y-2">
|
||||
{roster.roles.filter(r => !r.past).map(r => (
|
||||
<div key={r.id} className="border rounded-xl p-3 bg-white flex flex-wrap items-center justify-between gap-3" data-testid="open-role">
|
||||
<div>
|
||||
<div className="font-medium">{r.name}{r.kidsRoom ? <span className="text-xs text-gray-500"> · kids room {r.kidsRoom.name}</span> : null}</div>
|
||||
<div className="text-sm text-gray-600">{r.sessionLabel ? `${r.sessionLabel} · ` : ""}{r.shift.text}</div>
|
||||
{r.description && <div className="text-xs text-gray-500">{r.description}</div>}
|
||||
<div className="text-xs text-gray-500">{r.filled} of {r.needed} signed up</div>
|
||||
</div>
|
||||
{r.mine
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You're serving</span>
|
||||
: r.coverNeeded.length > 0
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
: r.full
|
||||
? <span className="text-sm text-gray-500">Full</span>
|
||||
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
</div>
|
||||
))}
|
||||
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,13 +55,16 @@ type Event = {
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
lateJoin?: LateJoinInfo | null;
|
||||
series?: EventSeriesRef | null;
|
||||
nextInSeries?: NextInSeries | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
import type { EventSeriesRef, EventSession, LateJoinInfo, NextInSeries, PublicBundle } from "@/types";
|
||||
import { SeriesLinks } from "@/components/events/SeriesLinks";
|
||||
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
|
||||
import { SessionSchedule } from "@/components/events/SessionSchedule";
|
||||
import { appUrl, appName } from "@/lib/siteConfig";
|
||||
@@ -222,6 +225,8 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
|
||||
<SeriesLinks series={event.series} next={event.nextInSeries} />
|
||||
|
||||
{event.scheduleType === "sessions" && (event.sessions || []).length > 0 && (
|
||||
<SessionSchedule sessions={event.sessions || []} defaultLocation={event.location} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
|
||||
import { SeasonPassOffers } from "@/components/series/SeasonPassOffers";
|
||||
import { Suspense } from "react";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
|
||||
import type { Event as FullEvent, SeasonPassView } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
|
||||
type PastEvent = { id: string; title: string; startDate: string; endDate: string; campus?: FullEvent["campus"] };
|
||||
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[]; passes?: SeasonPassView[] };
|
||||
|
||||
async function loadSeries(slug: string): Promise<Series | null> {
|
||||
try {
|
||||
return await apiFetch<Series>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const series = await loadSeries(slug);
|
||||
if (!series) return {};
|
||||
return {
|
||||
title: series.name,
|
||||
description: series.description || `Every ${series.name} event, past and upcoming.`,
|
||||
alternates: { canonical: `/series/${series.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
/** A series' page: what it is, the upcoming events in it (any campus), and past ones. */
|
||||
export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const series = await loadSeries(slug);
|
||||
if (!series) notFound();
|
||||
|
||||
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
const now = Date.now();
|
||||
const upcoming = (events || [])
|
||||
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
|
||||
.sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
|
||||
<Link href="/events" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> All events
|
||||
</Link>
|
||||
<div className="border rounded-2xl p-6 mb-8 bg-white shadow-sm flex flex-col sm:flex-row gap-6">
|
||||
{series.picture && <ApiImage src={series.picture} alt="" className="w-full sm:w-56 aspect-video object-cover rounded-xl shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs uppercase tracking-wide text-brand-700 flex items-center gap-1"><Layers className="w-3.5 h-3.5" /> Series</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mt-1">{series.name}</h1>
|
||||
{series.description && <p className="text-gray-700 mt-2 whitespace-pre-line">{series.description}</p>}
|
||||
<div className="mt-4">
|
||||
<FollowSeriesButton slug={series.slug} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<SeasonPassOffers slug={series.slug} passes={series.passes || []} />
|
||||
</Suspense>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Coming up</h2>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50 mb-10">
|
||||
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">Nothing planned yet — follow the series to hear when the next one is out.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2 mb-10">
|
||||
{upcoming.map(event => <EventCard key={event.id} event={event} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{series.past.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">Past events</h2>
|
||||
<ul className="border rounded-xl bg-white divide-y" data-testid="series-past">
|
||||
{series.past.map(e => (
|
||||
<li key={e.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium text-gray-800">{e.title}</span>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<CampusBadge campus={e.campus} className="text-[10px]" />
|
||||
{formatDateTimeRange(e.startDate, e.endDate)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { CheckCircle2, HandHeart } from "lucide-react";
|
||||
|
||||
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
|
||||
|
||||
/** Where the volunteer check-in QR code leads: checks the signed-in volunteer in for today's shifts. */
|
||||
function CheckIn() {
|
||||
const code = useSearchParams().get("code") || "";
|
||||
const { user, token, loading } = useAuth();
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
const [error, setError] = useState<string | null>(code ? null : "This check-in link is incomplete — scan the QR code again.");
|
||||
const sent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code || loading || !token || sent.current) return;
|
||||
sent.current = true;
|
||||
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
|
||||
.then(setResult)
|
||||
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
|
||||
}, [code, loading, token]);
|
||||
|
||||
if (error) return <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm" role="alert">{error} <Link href="/dashboard/user/serving" className="underline">My shifts</Link></div>;
|
||||
if (loading) return <div className="text-sm text-gray-600">Loading…</div>;
|
||||
if (!user) {
|
||||
const back = `/serve/check-in?code=${encodeURIComponent(code)}`;
|
||||
return (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<p className="text-sm text-gray-700">Log in to check in for your volunteer shift.</p>
|
||||
<Link href={`/login?redirect=${encodeURIComponent(back)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Log in</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result) return <div className="text-sm text-gray-600">Checking you in…</div>;
|
||||
return (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3" data-testid="volunteer-checked-in">
|
||||
<div className="flex items-center gap-2 text-green-700 font-semibold"><CheckCircle2 className="w-6 h-6" /> Thank you for serving, {user.name?.split(" ")[0]}!</div>
|
||||
<p className="text-sm text-gray-700">You're checked in at <strong>{result.event.title}</strong>:</p>
|
||||
<ul className="text-sm text-gray-800 list-disc pl-5">
|
||||
{result.checkedIn.map(c => <li key={c.role}>{c.role}{c.kidsRoom ? ` — you're on duty in ${c.kidsRoom}` : ""}</li>)}
|
||||
{result.already.map(r => <li key={r}>{r} (already checked in)</li>)}
|
||||
</ul>
|
||||
<Link href="/dashboard/user/serving" className="text-sm text-brand-600 hover:underline">My shifts</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VolunteerCheckInPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<HandHeart className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Volunteer check-in</h1>
|
||||
</div>
|
||||
<Suspense fallback={<div className="text-sm text-gray-600">Loading…</div>}>
|
||||
<CheckIn />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -21,9 +21,40 @@ export type HubStatus = {
|
||||
version: string;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
updateRequest: { requestedAt: string; version: string } | null;
|
||||
urgentUpdate: boolean;
|
||||
updateSnoozed: boolean;
|
||||
releases: HubRelease[];
|
||||
// `outdated`: a newer version than the one asked for is out (the request can be changed to it).
|
||||
updateRequest: { requestedAt: string; version: string; outdated?: boolean } | null;
|
||||
notices: HubNotice[];
|
||||
};
|
||||
|
||||
export type HubRelease = { version: string; releasedAt: string | null; urgent: boolean; notes: string };
|
||||
export type HubNotice = { id: string; level: "info" | "maintenance" | "warning"; title: string; message: string; startsAt: string | null; endsAt: string | null; updatedAt: string | null };
|
||||
|
||||
/** Release notes from the provider: lines starting with "- " are a list, the rest paragraphs. */
|
||||
export function ReleaseNotes({ notes }: { notes: string }) {
|
||||
const blocks: ({ list: false; text: string } | { list: true; items: string[] })[] = [];
|
||||
for (const raw of notes.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const item = /^[-*•]\s+(.*)$/.exec(line);
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (item) {
|
||||
if (last?.list) last.items.push(item[1]);
|
||||
else blocks.push({ list: true, items: [item[1]] });
|
||||
} else blocks.push({ list: false, text: line });
|
||||
}
|
||||
if (!blocks.length) return <p className="text-gray-400 italic">No details given.</p>;
|
||||
return (
|
||||
<div className="space-y-1.5 text-gray-700">
|
||||
{blocks.map((b, i) => (b.list
|
||||
? <ul key={i} className="list-disc pl-5 space-y-0.5">{b.items.map((t, j) => <li key={j}>{t}</li>)}</ul>
|
||||
: <p key={i}>{b.text}</p>))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
|
||||
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
|
||||
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
|
||||
@@ -94,18 +125,27 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const requestUpdate = async () => {
|
||||
// Request, withdraw, "Not now" and "remind me again" all return the new status.
|
||||
const updateAction = async (path: string, body: unknown, ok: string) => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
|
||||
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
|
||||
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
|
||||
setMsg({ type: "ok", text: ok });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
|
||||
// Same request again: the Hub moves the open request on to the latest version.
|
||||
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
|
||||
const withdrawRequest = () => {
|
||||
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
|
||||
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
|
||||
};
|
||||
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
|
||||
|
||||
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const st = STATE[status.state];
|
||||
@@ -162,22 +202,92 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
<div className="border rounded-xl p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
|
||||
<p>This site runs Cross Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
|
||||
{status.updateAvailable && (status.updateRequest ? (
|
||||
<p className="mt-1 text-gray-600">
|
||||
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
|
||||
{status.connected && (
|
||||
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
|
||||
</button>
|
||||
{status.updateAvailable && (
|
||||
<div className="mt-2 space-y-3">
|
||||
{status.urgentUpdate && (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">
|
||||
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>This update fixes a security problem or a serious bug. Please ask for it soon.</span>
|
||||
</p>
|
||||
)}
|
||||
{status.updateRequest ? (
|
||||
<div className="space-y-2" data-testid="update-request">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-gray-600">
|
||||
You asked for version <strong>{status.updateRequest.version}</strong> on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
|
||||
</p>
|
||||
{status.connected && (
|
||||
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<Undo2 className="w-4 h-4" /> Withdraw request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.updateRequest.outdated && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-brand-200 bg-brand-50 p-3">
|
||||
<p className="text-brand-800">Version <strong>{status.latestVersion}</strong> has come out since. Ask for it instead, and your provider installs that one.</p>
|
||||
{status.connected && (
|
||||
<button onClick={changeRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : `Ask for ${status.latestVersion} instead`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-brand-700">Version {status.latestVersion} is available.{status.updateSnoozed && <span className="text-gray-500"> You chose "Not now", so there are no reminders until the next version.</span>}</p>
|
||||
{status.connected && (
|
||||
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : "Request update"}
|
||||
</button>
|
||||
)}
|
||||
{!status.urgentUpdate && (status.updateSnoozed ? (
|
||||
<button onClick={() => snooze(false)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<BellRing className="w-4 h-4" /> Remind me again
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => snooze(true)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<BellOff className="w-4 h-4" /> Not now
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{status.releases.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-gray-900 mb-2">What's new</h3>
|
||||
<div className="space-y-4">
|
||||
{status.releases.map(r => (
|
||||
<div key={r.version}>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span className="font-semibold">{r.version}</span>
|
||||
{r.urgent && <span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 px-2 py-0.5 text-xs font-medium text-red-700"><ShieldAlert className="w-3 h-3" /> Security or urgent fix</span>}
|
||||
{r.releasedAt && <span className="text-xs text-gray-500">{new Date(r.releasedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>}
|
||||
</div>
|
||||
<ReleaseNotes notes={r.notes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.notices.length > 0 && (
|
||||
<div className="border rounded-xl p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-gray-900 mb-2"><Megaphone className="w-4 h-4" /> From your provider</div>
|
||||
<div className="space-y-3">
|
||||
{status.notices.map(n => (
|
||||
<div key={n.id}>
|
||||
<p className="font-medium">{n.title}</p>
|
||||
<p className="text-gray-700 whitespace-pre-line">{n.message}</p>
|
||||
{n.endsAt && <p className="text-xs text-gray-500 mt-0.5">Until {fmt(n.endsAt)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type EventType = { type: string; label: string; description: string };
|
||||
type Endpoint = {
|
||||
id: string; name: string; url: string; events: string[]; isActive: boolean;
|
||||
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
|
||||
lastWeek?: Record<string, number>;
|
||||
};
|
||||
type Delivery = {
|
||||
id: string; eventId: string; type: string; status: string; attempts: number; nextAttemptAt: string;
|
||||
lastStatusCode: number | null; lastError: string | null; deliveredAt: string | null; createdAt: string; payload: unknown;
|
||||
};
|
||||
type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endpoint[] };
|
||||
type Draft = { id?: string; name: string; url: string; events: string[] };
|
||||
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
const STATUS: Record<string, string> = {
|
||||
succeeded: "bg-green-50 text-green-800", failed: "bg-red-50 text-red-700", retrying: "bg-amber-50 text-amber-800",
|
||||
pending: "bg-gray-100 text-gray-600", sending: "bg-blue-50 text-blue-700",
|
||||
};
|
||||
|
||||
/** Shown once after adding an endpoint or making a new secret. */
|
||||
function SecretBox({ secret, onClose }: { secret: string; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm space-y-2" data-testid="webhook-secret">
|
||||
<p className="font-medium text-amber-900">Signing secret — copy it now, it won't be shown again</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded bg-white border px-2 py-1 text-xs">{secret}</code>
|
||||
<button type="button" className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"
|
||||
onClick={() => { navigator.clipboard?.writeText(secret).then(() => setCopied(true)).catch(() => {}); }}>
|
||||
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-amber-900">
|
||||
Each request has an <code>X-CrossEvents-Signature: t=…,v1=…</code> header: v1 is the HMAC-SHA256 (hex) of <code><t>.<body></code> with this secret. Check it, and that t is recent, before trusting a request.
|
||||
</p>
|
||||
<button type="button" className="text-xs underline text-amber-900" onClick={onClose}>I've saved it</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Deliveries({ endpointId, token }: { endpointId: string; token: string }) {
|
||||
const [rows, setRows] = useState<Delivery[] | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const load = useCallback(async () => {
|
||||
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
|
||||
}, [endpointId, token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const resend = async (id: string) => {
|
||||
setBusy(id);
|
||||
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
|
||||
setBusy(null);
|
||||
load();
|
||||
};
|
||||
if (!rows) return <p className="text-xs text-gray-400">Loading…</p>;
|
||||
if (!rows.length) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
|
||||
return (
|
||||
<div className="space-y-1" data-testid="webhook-deliveries">
|
||||
<div className="flex justify-end"><button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" /> Refresh</button></div>
|
||||
<ul className="divide-y border rounded-lg bg-white">
|
||||
{rows.map(d => (
|
||||
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen(open === d.id ? null : d.id)} className="text-gray-500" aria-label="Show what was sent">
|
||||
{open === d.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<span className="text-gray-500 w-28">{when(d.createdAt)}</span>
|
||||
<code className="font-medium">{d.type}</code>
|
||||
<span className={`px-1.5 py-0.5 rounded ${STATUS[d.status] || "bg-gray-100"}`}>{d.status}</span>
|
||||
<span className="text-gray-500">
|
||||
{d.attempts} tr{d.attempts === 1 ? "y" : "ies"}{d.lastStatusCode ? ` · ${d.lastStatusCode}` : ""}{d.status === "retrying" ? ` · next ${when(d.nextAttemptAt)}` : ""}
|
||||
</span>
|
||||
{d.lastError && <span className="text-red-700 truncate max-w-[16rem]" title={d.lastError}>{d.lastError}</span>}
|
||||
{d.status !== "sending" && (
|
||||
<button type="button" disabled={busy === d.id} onClick={() => resend(d.id)} className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">
|
||||
<RotateCcw className="w-3 h-3" /> Resend
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open === d.id && <pre className="mx-3 mb-2 max-h-64 overflow-auto rounded bg-gray-50 p-2 text-[11px]">{JSON.stringify(d.payload, null, 2)}</pre>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings → Webhooks: send what happens here (registrations, payments, check-ins…) to other systems.
|
||||
export function WebhooksTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<ListResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [secret, setSecret] = useState<{ endpointId: string; value: string } | null>(null);
|
||||
const [openLog, setOpenLog] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true); setMsg(null);
|
||||
try { await fn(); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const save = () => run(async () => {
|
||||
if (!draft) return;
|
||||
const body = { name: draft.name, url: draft.url, events: draft.events };
|
||||
if (draft.id) {
|
||||
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
setMsg({ type: "ok", text: "Webhook saved." });
|
||||
} else {
|
||||
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
|
||||
setSecret({ endpointId: made.id, value: made.secret });
|
||||
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
|
||||
}
|
||||
setDraft(null);
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggle = (e: Endpoint) => run(async () => {
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
|
||||
await load();
|
||||
});
|
||||
const test = (e: Endpoint) => run(async () => {
|
||||
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
|
||||
setMsg(r.status === "succeeded"
|
||||
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
|
||||
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
|
||||
if (openLog === e.id) setOpenLog(null);
|
||||
await load();
|
||||
});
|
||||
const rotate = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
|
||||
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
|
||||
setSecret({ endpointId: e.id, value: r.secret });
|
||||
});
|
||||
const remove = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
|
||||
|
||||
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><Webhook className="w-5 h-5" /> Webhooks</h2>
|
||||
<p className="text-sm text-gray-500 max-w-2xl">
|
||||
Send what happens here — registrations, payments, check-ins, new accounts — to another system as it happens (e.g. ChurchSuite or Planning Center through Zapier or Make).
|
||||
</p>
|
||||
</div>
|
||||
{!draft && <button type="button" onClick={() => { setMsg(null); setDraft({ name: "", url: "", events: [] }); }} className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 font-medium">+ Add webhook</button>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 bg-gray-50 border rounded-lg p-3">
|
||||
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children's details from kids check-in are never sent.
|
||||
</p>
|
||||
|
||||
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
|
||||
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
|
||||
|
||||
{draft && (
|
||||
<div className="border rounded-xl p-4 space-y-3 bg-white" data-testid="webhook-form">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-name">Name</label>
|
||||
<input id="webhook-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. ChurchSuite sync" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-url">Address (https)</label>
|
||||
<input id="webhook-url" className="w-full border rounded px-3 py-2 text-sm" value={draft.url} onChange={e => setDraft({ ...draft, url: e.target.value })} placeholder="https://…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Send when</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{data.eventTypes.map(t => (
|
||||
<label key={t.type} className="flex items-start gap-2 text-sm border rounded-lg p-2 cursor-pointer hover:bg-gray-50">
|
||||
<input type="checkbox" className="mt-1" checked={draft.events.includes(t.type)} onChange={() => toggleEvent(t.type)} />
|
||||
<span><span className="font-medium">{t.label}</span> <code className="text-[10px] text-gray-500">{t.type}</code><span className="block text-xs text-gray-500">{t.description}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={busy || !draft.name.trim() || !draft.url.trim() || !draft.events.length} onClick={save} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Saving…" : draft.id ? "Save webhook" : "Add webhook"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.endpoints.length === 0 && !draft ? (
|
||||
<p className="text-sm text-gray-500 border rounded-xl p-4">No webhooks yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.endpoints.map(e => (
|
||||
<li key={e.id} className="border rounded-xl p-4 bg-white space-y-2" data-testid="webhook-endpoint">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{e.name}
|
||||
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Off</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 break-all">{e.url}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">{e.events.map(t => <span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">{label(t)}</span>)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Last delivered {when(e.lastSuccessAt)}{e.lastFailureAt && <> · last failure {when(e.lastFailureAt)}</>}
|
||||
{e.lastWeek && Object.keys(e.lastWeek).length > 0 && <> · this week: {Object.entries(e.lastWeek).map(([s, n]) => `${n} ${s}`).join(", ")}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busy} onClick={() => test(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send test</button>
|
||||
<button type="button" onClick={() => { setMsg(null); setDraft({ id: e.id, name: e.name, url: e.url, events: e.events }); }} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
|
||||
<button type="button" disabled={busy} onClick={() => toggle(e)} className="text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">{e.isActive ? "Switch off" : "Switch on"}</button>
|
||||
<button type="button" disabled={busy} onClick={() => rotate(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><KeyRound className="w-3.5 h-3.5" /> New secret</button>
|
||||
<button type="button" disabled={busy} onClick={() => remove(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded bg-red-50 text-red-700 border border-red-200 disabled:opacity-50"><Trash2 className="w-3.5 h-3.5" /> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenLog(openLog === e.id ? null : e.id)} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline">
|
||||
{openLog === e.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Delivery log
|
||||
</button>
|
||||
{openLog === e.id && token && <Deliveries endpointId={e.id} token={token} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'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 "Significant change" 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,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
/** Follow / unfollow a series. Signed out, it links to the login page and back. */
|
||||
export function FollowSeriesButton({ slug }: { slug: string }) {
|
||||
const { user, token, loading } = useAuth();
|
||||
const [following, setFollowing] = useState<boolean | null>(null);
|
||||
const [seriesId, setSeriesId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The page itself is cached for everyone, so whether this person follows (and the series as it
|
||||
// is now) is asked separately.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<{ id: string; following: boolean }>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { authToken: token })
|
||||
.then(s => { setSeriesId(s.id); setFollowing(!!s.following); })
|
||||
.catch(() => setFollowing(null));
|
||||
}, [slug, token]);
|
||||
|
||||
if (loading) return null;
|
||||
if (!user) {
|
||||
return (
|
||||
<a href={`/login?redirect=/series/${slug}`} className="inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50">
|
||||
<Bell className="w-4 h-4" /> Log in to follow
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ following: boolean }>(`/api/series/${seriesId}/follow`, { method: following ? "DELETE" : "POST", authToken: token || undefined });
|
||||
setFollowing(res.following);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't change that");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={toggle} disabled={busy || following === null || !seriesId}
|
||||
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium disabled:opacity-50 ${following ? "border bg-white text-gray-700 hover:bg-gray-50" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
|
||||
{following ? <><BellOff className="w-4 h-4" /> Unfollow</> : <><Bell className="w-4 h-4" /> Follow this series</>}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mt-1.5" role="status">
|
||||
{following
|
||||
? "You'll get an email when a new event in this series is out (if you get news and announcements from us)."
|
||||
: "Get an email when the next one is out."}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Layers } from "lucide-react";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import type { EventSeriesRef, NextInSeries } from "@/types";
|
||||
|
||||
/** On an event page: the series it belongs to, and the next event in that series. */
|
||||
export function SeriesLinks({ series, next }: { series?: EventSeriesRef | null; next?: NextInSeries | null }) {
|
||||
if (!series) return null;
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-2" data-testid="event-series">
|
||||
<p className="text-sm text-gray-600 flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 shrink-0 text-brand-600" />
|
||||
Part of <Link href={`/series/${series.slug}`} className="font-medium text-brand-700 hover:underline">{series.name}</Link>
|
||||
</p>
|
||||
{next && (
|
||||
<Link href={`/events/${next.id}`} className="flex items-center justify-between gap-3 rounded-lg bg-brand-50 px-3 py-2 hover:bg-brand-100" data-testid="next-in-series">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[11px] uppercase tracking-wide text-brand-700">Next in this series</span>
|
||||
<span className="block text-sm font-medium text-gray-900 truncate">{next.title}</span>
|
||||
<span className="block text-xs text-gray-500">{formatDateTimeRange(next.startDate, next.endDate)}</span>
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 shrink-0 text-brand-700" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function RoomBoard({ board, token, roomId, onChanged, onMessage }: Props)
|
||||
<RoomStatusBadges room={r} />
|
||||
</div>
|
||||
{r.leaderNames.length > 0 && <div className="text-xs text-gray-500 mb-2 flex items-center gap-1"><Users className="w-3.5 h-3.5" /> On duty: {r.leaderNames.join(", ")}</div>}
|
||||
{(r.rostered || []).some(v => !v.checkedIn) && <div className="text-xs text-gray-500 mb-2">Rostered, not checked in yet: {r.rostered.filter(v => !v.checkedIn).map(v => v.name).join(", ")}</div>}
|
||||
{inRoom.length === 0 && <p className="text-sm text-gray-500">No children in this room.</p>}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{inRoom.map(c => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, Baby, type LucideIcon } from "lucide-react";
|
||||
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, Baby, HandHeart, type LucideIcon } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
|
||||
import { UserAvatar } from "@/components/shared/UserAvatar";
|
||||
@@ -13,6 +13,7 @@ const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[
|
||||
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/kids", label: "Kids", icon: Baby, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/serving", label: "Serving", icon: HandHeart, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/privacy", label: "Privacy", icon: Lock, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
HeartPulse,
|
||||
MessageSquareHeart,
|
||||
Filter,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -42,6 +43,7 @@ export const REPORTS = [
|
||||
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
|
||||
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "seriesComparison", label: "Series: year on year", description: "Registrations, income and attendance compared across the events in a series", category: "Other", icon: Layers, fields: 6 },
|
||||
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user