Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37731a4696 | ||
|
|
4752516b15 | ||
|
|
5dea712b72 | ||
|
|
f1714f9286 | ||
|
|
c0d01ce993 | ||
|
|
0414854dec | ||
|
|
dd537a1a16 | ||
|
|
2240c8a703 | ||
|
|
a7c8148588 | ||
|
|
96d1e67b85 | ||
|
|
e5d615e398 | ||
|
|
0253a1d5fa | ||
|
|
740897635f | ||
|
|
7ae0b6d23b | ||
|
|
6ef19d3290 | ||
|
|
51355069da | ||
|
|
3a96f9c1dc | ||
|
|
a7261db191 | ||
|
|
6045d1b05e | ||
|
|
5df04f5a4a | ||
|
|
35805f0c79 | ||
|
|
174022296b | ||
|
|
bc22a94f92 | ||
|
|
bbde6791cb | ||
|
|
e9a2ce9d01 | ||
|
|
39885a8719 | ||
|
|
1ce6f23ad8 | ||
|
|
1dbc37ba08 | ||
|
|
42a369278e | ||
|
|
e249d43c79 | ||
|
|
00b465b97d | ||
|
|
794396ca9c | ||
|
|
09ef2c1df1 | ||
|
|
36c3b117ca | ||
|
|
cad075bb3f | ||
|
|
159728d269 | ||
|
|
c570fc7ffb | ||
|
|
00fd26f3a5 | ||
|
|
344f260011 | ||
|
|
d2d9282a2c | ||
|
|
f36e84fd90 | ||
|
|
a6909ce506 | ||
|
|
955f8d9831 | ||
|
|
2727fc6554 | ||
|
|
3d149e6098 | ||
|
|
30a2142085 | ||
|
|
95913a5bd5 | ||
|
|
f7e0ec8bc1 | ||
|
|
08d442e1c1 | ||
|
|
be4f341e42 | ||
|
|
1caa0f0ea7 | ||
|
|
91f6481fb7 | ||
|
|
bf887720a1 | ||
|
|
6de0b5525b | ||
|
|
e261705214 | ||
|
|
4ef9cea634 | ||
|
|
6f18e40f06 | ||
|
|
7fdd2b8b9d | ||
|
|
8e2cb7eb4d | ||
|
|
bc73d7e3af | ||
|
|
925252a1f2 | ||
|
|
60726b71ce | ||
|
|
e58787ae7a | ||
|
|
8f533e1f61 | ||
|
|
3c5246424b | ||
|
|
bbdceb38b6 | ||
|
|
c0965bfcf5 | ||
|
|
eb2159fa33 | ||
|
|
0551226de5 | ||
|
|
1ae9c45eed | ||
|
|
fd2bb07047 |
@@ -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'"
|
||||
+423
@@ -7,6 +7,429 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.48.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
|
||||
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
|
||||
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
|
||||
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
|
||||
with families, children, rooms and check-ins (including some checked in right now), volunteer
|
||||
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
|
||||
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
|
||||
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
|
||||
people. See `backend/DEMO_DATA.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
|
||||
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
|
||||
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
|
||||
to them are cleared.
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
|
||||
who sees the roster and cover requests), following an event series, season passes, webhooks
|
||||
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
|
||||
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
|
||||
version show the new text straight away.
|
||||
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
|
||||
what changed in the built-in text since they last reviewed it, marks each section with new text
|
||||
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
|
||||
sections the notice doesn't have. **Done — hide this** records the review
|
||||
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
|
||||
"Significant change" if people should accept again) to make it live.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agreeing to the notice when an account was made for you** — activating an account (made by
|
||||
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
|
||||
Privacy Policy, as sign-up does (consent log source "Account activation").
|
||||
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
|
||||
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
|
||||
the built-in notice is in use. Before, they were only asked after a version marked as a
|
||||
significant change was published.
|
||||
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
|
||||
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
|
||||
|
||||
## [1.48.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
|
||||
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
|
||||
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
|
||||
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
|
||||
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
|
||||
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
|
||||
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
|
||||
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
|
||||
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
|
||||
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
|
||||
moment later, so a change that was rolled back sends nothing.
|
||||
- Safety and privacy: https addresses on public networks only (checked again at each send;
|
||||
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
|
||||
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
|
||||
days and with an anonymised account.
|
||||
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
|
||||
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
|
||||
|
||||
## [1.47.4] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Update requested, then a newer version came out** — the Licence page said the newer version
|
||||
was requested ("Version 1.47.3 is available. Update requested …") when the request was for an
|
||||
older one, and there was no way to ask for the newer version; the update banner stayed hidden.
|
||||
Now it shows the request ("You asked for version 1.47.0 on …") and, below it, that a newer
|
||||
version has come out with **Ask for 1.47.3 instead** (Withdraw request stays). The banner shows
|
||||
again and says to change the request. Changing it is audited as `hub_update_requested` with
|
||||
`changedFrom`.
|
||||
- With Cross Events Hub 1.5.0 the Hub moves the open request on to the new version and emails the
|
||||
provider once; with an older Hub the site withdraws the request and makes it again.
|
||||
|
||||
## [1.47.3] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Season passes** — one price for a ticket at each of several events in a series. On the Event
|
||||
series page supervisors make a pass (name, price, optional sale end) and tick which events and
|
||||
which ticket (and variant) at each it includes; the form shows what they'd cost separately. Youth
|
||||
(parental consent) events, staff-only tickets and per-session tickets can't be on a pass. Events
|
||||
and price are fixed once someone has bought it (switch it off instead); campus-scoped supervisors
|
||||
can only use their campuses' events.
|
||||
- **Buying** — on the series page signed-in people choose how many and buy: they're registered at
|
||||
every event on the pass at once and pay once (a free pass gives the tickets straight away). If
|
||||
online payment isn't possible the places stay booked and "Your passes" offers **Pay now**. A pass
|
||||
stops selling when its first event starts.
|
||||
- **Money per event** — the price is split over the events in proportion to their normal ticket
|
||||
prices (whole cents). Each registration's line is priced at its event's share, and the payment is
|
||||
recorded as one payment per event (all linked to the purchase), so each event's cashup and reports
|
||||
count only its part. Pass registrations aren't repriced by early-bird changes, take no coupon
|
||||
codes, and their tickets can't be edited.
|
||||
- `/api/season-passes` (list, create, change, delete; `:id/buy`; `purchases/:id/checkout`; `mine`),
|
||||
`passes` on `GET /api/series/by-slug/:slug`. Making, changing and buying passes is blocked in
|
||||
licence read-only mode. Purchases are in "Download my data". Audit action `season_pass_changed`
|
||||
(migration `20261028150000_season_pass`).
|
||||
|
||||
## [1.47.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
|
||||
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
|
||||
counted, donations applied to orders left out) and people checked in (a multi-session event shows
|
||||
the average per session), with the attendance rate and the change from the event before. Bar charts
|
||||
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
|
||||
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
|
||||
page opens it (`?report=seriesComparison&series=<id>`).
|
||||
- `GET /api/series/:id/compare`.
|
||||
|
||||
## [1.47.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
|
||||
one page. Supervisors make series on the new **Event series** page (name, web address,
|
||||
description, picture) and put an event in one with the **Series** box in the event editor; a
|
||||
copied event keeps its series. A series can have events at several campuses.
|
||||
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
|
||||
series shows "Part of …" and **Next in this series** on its page.
|
||||
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
|
||||
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
|
||||
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
|
||||
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
|
||||
series' followers.
|
||||
- Followed series are in "Download my data" and removed when an account is anonymised.
|
||||
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
|
||||
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
|
||||
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
|
||||
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
|
||||
`20261028140000_event_series`).
|
||||
|
||||
## [1.47.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Copy an event** — "Copy" on each card on the Events page opens the event editor filled in from
|
||||
that event: details, campus, sessions, ticket options, variants and early-bird tiers, sections,
|
||||
the registration form, notification recipients, the feedback questions, certificates and session
|
||||
reminders. Everything can be changed before saving. A **New start** box at the top moves every
|
||||
date by the same amount — the end, the sessions, the registration deadline, early-bird deadlines
|
||||
and the feedback date. On save the event's allocation groups (rooms, buses, teams) are copied
|
||||
too, and for admins its planned costs (a cost on a ticket option goes to the option with the
|
||||
same name). Not copied: registrations, coupons, attachments, bundles, the short link, the go-live
|
||||
time, cancelled sessions, kids check-in (kids ticket and rooms) and volunteer roles.
|
||||
- `POST /api/events/:id/copy-from` (blocked in licence read-only mode, campus-checked for both
|
||||
events); `POST /api/events` takes `clonedFromId`, recorded in the `event_created` audit entry.
|
||||
|
||||
## [1.46.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **What's new before you update** — Site settings → Licence shows the release notes for every
|
||||
version newer than the site, so you can decide whether to ask for the update. Updates that fix a
|
||||
security problem or a serious bug are marked, and show as a red banner.
|
||||
- **Withdraw request** — changed your mind after pressing Request update? Withdraw it on the Licence
|
||||
page and your provider is told not to update the site.
|
||||
- **Not now** — on the update banner or the Licence page: hides the "update available" reminder until
|
||||
the next version comes out ("Remind me again" turns it back on). Urgent updates can't be put off.
|
||||
- **Notices from your provider** — planned maintenance and other messages from your provider show as
|
||||
a banner at the top of the dashboard for site admins and supervisors, at the times given. Close
|
||||
one with ×; current notices are also listed on the Licence page.
|
||||
|
||||
- **`WHATS-NEW.md`** — the release notes churches see, in plain language. The Hub reads it from git
|
||||
with the version tags, so a release needs its section there before tagging.
|
||||
|
||||
Needs Cross Events Hub 1.4.0 for the notes, withdrawing and notices; with an older Hub the site
|
||||
works as before.
|
||||
|
||||
## [1.46.1] - 2026-10-28
|
||||
|
||||
### Removed
|
||||
|
||||
- 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
|
||||
|
||||
- **Privacy notice (built-in text)** brought up to date: kids check-in (what's kept about
|
||||
children and authorised adults, who sees allergies, medical and custody notes, pickup codes,
|
||||
offline desk lists, the 6-month deletion), multi-session attendance, session reminders,
|
||||
certificates and their public check, home campus, WhatsApp sign-in codes, and campus-limited
|
||||
staff. "Stored on South African servers" is replaced by where the data really is: the site's
|
||||
own database at the hosting provider in the operator list. It now also says what the site sends
|
||||
Cross Code's licence server (health and totals, income as a range — no personal information),
|
||||
and lists the kids check-in browser storage.
|
||||
Sites that have already published their own notice keep it: review the changes and publish a
|
||||
new version from Admin → Privacy → Privacy notice.
|
||||
- **Terms of use:** a new "Kids Check-in" section (pickup codes, parents' responsibility for
|
||||
their children's details), sessions and certificates, kids messages among the required
|
||||
messages, and account deletion from the Privacy page. Later sections are renumbered.
|
||||
|
||||
## [1.45.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Kids check-in** — a secure, fully digital check-in and pickup for children's ministry, run
|
||||
from a staff desk. No label printers needed.
|
||||
- **Families are kept between events** (weekly kids church): parents add their children under
|
||||
My account → **Kids** (date of birth, allergies, medical and custody notes) and list
|
||||
**authorised adults** who may collect them (no account needed). Staff can find a family by
|
||||
name or phone, scan the parent's family QR code, or add a new family on the spot.
|
||||
- **Pickup codes:** checking in gives the family a 4-character code for the day (or session),
|
||||
shared by the children checked in together. It shows on the parent's phone with a QR code and
|
||||
is sent by WhatsApp or email (and, if chosen, to an authorised adult). Anyone with the code can
|
||||
collect.
|
||||
- **"Write on the sticker" panel:** first name, room, code and a short ALLERGY/MEDICAL alert in
|
||||
large print, for volunteers to copy onto a blank sticker.
|
||||
- **Rooms** with an age range, capacity and ratio (children per leader). The desk suggests the
|
||||
room from the child's age; the room screen shows live cards with allergies, medical and custody
|
||||
notes, "I'm on duty", and warns when a room is full or over the ratio.
|
||||
- **Check-out** by typing or scanning the code. A wrong code is refused, shown as an alert on the
|
||||
room screens and recorded in the audit log; checking out without the code needs a reason.
|
||||
"Call parent" asks the parents to come to the room.
|
||||
- **Offline desk:** download the families and 20 pickup codes for the device beforehand, keep
|
||||
checking in without a connection, and sync afterwards.
|
||||
- Children show on the **attendance register** and reports: each gets a free place on the event's
|
||||
kids ticket the first time, and every check-in counts as attendance.
|
||||
- Set up per event under Supervisor → **Kids check-in setup**; works per campus.
|
||||
- **Privacy:** allergies, medical and custody notes are encrypted and never put in messages;
|
||||
opening the desk or room screens is written to the access log; families are in "Download my
|
||||
data" and anonymising; a new retention rule deletes a child's profile after **6 months**
|
||||
without a check-in (on by default); a readiness check for it.
|
||||
- Rooms in Rooms & groups take an age range and a ratio (`minAge`, `maxAge`, `ratio`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Apple and Google Wallet tickets are removed from the 2.0 plan.
|
||||
|
||||
## [1.44.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses", while campuses are on). A
|
||||
supervisor or staff member limited to some campuses sees only those campuses' events plus
|
||||
events for all campuses — in every list, report, dashboard, the scanner and offline scanning.
|
||||
- **Events for all campuses:** they can scan, register people and take payments at the door, but
|
||||
not change them, cash them up or refund them (the event editor opens read-only).
|
||||
- **New events** go at one of their campuses; they can't move events to another campus.
|
||||
- **Admins and supervisors for every campus only:** the site banner, payment reconciliation and
|
||||
open online checkouts, and donations not linked to an event.
|
||||
- **Messages:** broadcasts only to people at their campuses or attendees of their events (no
|
||||
typed-in addresses). **Coupons:** only for their campuses' events.
|
||||
- Another campus's records answer "not found"; the **scanner says which campus** a ticket is
|
||||
for. Staff still see their own registrations for any campus's event.
|
||||
- Their dashboard says which campuses they work with. Admins are never limited, and limits are
|
||||
ignored while campuses are off (the Campuses tab warns first). Changing someone's limits is in
|
||||
the audit log; a campus with staff limited to it can't be deleted.
|
||||
- Every staff and supervisor route declares how it handles campus limits, and a test fails on
|
||||
any that doesn't (`tests/campusScopeRoutes.test.js`).
|
||||
|
||||
## [1.43.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Campuses** (Site settings → Campuses, off by default) for a church with more than one campus
|
||||
in one install. Churches with one campus see no campus fields anywhere.
|
||||
- Admins turn campuses on, then add, order, archive and delete campuses: name, web address,
|
||||
address, contact, colour, logo, default event location, and staff who get that campus's daily
|
||||
summary. A campus with events can't be deleted, only archived. Turning campuses off hides
|
||||
everything campus-related and deletes nothing.
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills
|
||||
in its default location; moving an event to another campus is in the audit log. The Events
|
||||
list in the dashboard shows each event's campus and has a campus filter.
|
||||
- **Public site:** a campus filter on the events page, remembered in the visitor's browser and
|
||||
starting on their home campus; a campus shows its own events plus church-wide ones. The campus
|
||||
shows on event cards, the event page and ticket PDFs. **Campus pages** at `/campus/<slug>`
|
||||
(also in the sitemap).
|
||||
- **Home campus** on the profile page.
|
||||
- **Reports and dashboards:** a campus filter on Reports (all reports and the funnel) and on the
|
||||
admin and supervisor dashboards' past-month tiles, with a church-wide total.
|
||||
- **Broadcasts:** "Everyone at a campus" in Email and WhatsApp broadcasts — people whose home
|
||||
campus it is, plus (ticked) people who registered for its events in the last 12 months. News
|
||||
consent still applies.
|
||||
- **Daily summary:** campus recipients also get the summary of their campus's events.
|
||||
- The Hub heartbeat reports whether campuses are on and how many there are (`campusCount`).
|
||||
- Campus-scoped staff (supervisors and staff limited to some campuses) follow in 1.44.0.
|
||||
- The privacy notice lists the new `hope_events_campus` browser-storage item.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Request update" (1.42.0) wasn't written to the audit log: the `hub_update_requested` action was
|
||||
missing from the database. The request itself always worked.
|
||||
|
||||
## [1.42.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **The production Hub's public key is built in** (`backend/src/hub/publicKey.js`), so sites can
|
||||
check licences without `HUB_PUBLIC_KEY` in their `.env`. `HUB_PUBLIC_KEY` still overrides it (for
|
||||
a local Hub while developing).
|
||||
|
||||
## [1.42.0] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Request update" instead of the deploy command.** When a new version is out, Site settings →
|
||||
Licence shows a **Request update** button instead of `bash scripts/deploy.sh …`. It sends a
|
||||
signed request to the Hub (`docs/hub-protocol.md` §3a), which tells the provider; the site never
|
||||
updates itself. Afterwards it shows "Update requested on …" until the site runs that version, and
|
||||
the "update available" banner for admins goes away. Logged in the audit log
|
||||
(`hub_update_requested`). Needs Cross Events Hub 1.1.0; an older Hub answers "The Hub doesn't take
|
||||
update requests yet".
|
||||
|
||||
## [1.41.3] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **The platform is now called Cross Events** (and the Hub, Cross Events Hub): Site settings →
|
||||
Licence, the "update available" banner, the setup wizard, the audit log, help and the docs.
|
||||
Technical names stay as they are (`hope-events` repo, PM2 and nginx names, install folder, browser
|
||||
storage keys), so existing installs and sign-ins keep working.
|
||||
|
||||
## [1.41.2] - 2026-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background jobs page showed last-run times two hours ahead** (e.g. 12:55 at 10:55). Prisma's
|
||||
Postgres adapter dropped the time-zone offset of pg-boss's timestamps; the query now reads them
|
||||
as UTC. Job history itself was never wrong. `APP_TIMEZONE` is now in `backend/.env.example` and
|
||||
SETTINGS.md.
|
||||
|
||||
## [1.41.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **No licence now means read-only**, the same as a suspended licence: a site that isn't connected
|
||||
to the Hub can't create events, registrations or payments until a licence key is connected
|
||||
(admins see a red banner). `LICENCE_CHECK_DISABLED=true` switches this off outside production,
|
||||
for the end-to-end tests.
|
||||
- **Read-only mode also blocks editing events**, their options and their variants (suspended,
|
||||
revoked and unlicensed sites).
|
||||
- The site **checks in with the Hub 30 seconds after starting**, and 30 seconds after a licence
|
||||
key is connected, instead of waiting up to 15 minutes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A site the Hub no longer knows (`INSTALL_NOT_FOUND`, e.g. the licence was released) kept showing
|
||||
"Active" on its old licence. It now drops the connection and licence, and shows "No licence".
|
||||
|
||||
## [1.41.0] - 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Hope Events Hub connection** (`docs/hub-protocol.md`). Each install connects to the Hub
|
||||
(`events-hub.crosscode.co.za`) with a licence key:
|
||||
- **Licence step in `/setup`** (on the last screen), and **Site settings → Licence** for existing
|
||||
installs: connect, see the licence (licensed to, plan, end date, grace period), the last check-in,
|
||||
and "Check now".
|
||||
- A **heartbeat every 15 minutes** (`hub-heartbeat` job; `HUB_HEARTBEAT_ENABLED=false` to switch off):
|
||||
version, health (database, background jobs, last backup, free disk, server errors, pending
|
||||
migrations) and totals (users, events, registrations, tickets, check-ins, payment providers, income
|
||||
as a range only). Never anyone's personal information.
|
||||
- **Licences are checked on the site** with the Hub's public key, so a Hub outage never switches a
|
||||
site off and a licence can't be faked by editing the database:
|
||||
- **Grace period** (licence past its end date, default 14 days): everything works; **only site
|
||||
admins** see "Licence needs renewing — X days left".
|
||||
- **Suspended or revoked**: read-only — no new events, registrations, tickets, payments, waitlist
|
||||
places, coupons or bundles. Tickets still scan; all data can still be viewed and exported;
|
||||
refunds, cancellations and privacy requests still work.
|
||||
- Not connected yet: works as before; admins see "Connect to the Hub".
|
||||
- **Update available**: admins see when a newer version is out, with the command to run.
|
||||
- **`/health`** — status, version, database, background jobs and last backup, for monitoring.
|
||||
- **Install script** (`scripts/install.sh`): a fresh Ubuntu server to a running site in one command —
|
||||
Node 20, PostgreSQL, PM2, nginx, HTTPS, configuration, migrations, build; then `/setup`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The generic settings endpoints never read or write the Hub connection (install id, secret, licence).
|
||||
|
||||
## [1.40.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
# Deploying Cross Events behind frp
|
||||
|
||||
This guide is for a server that is **not reachable from the internet directly**: it runs
|
||||
`frpc`, which opens a tunnel to `frps` on a public server (the "VPS"). Visitors reach the VPS,
|
||||
and frp carries the traffic through to this server. The frp client config lives at
|
||||
**`/etc/frp/frpc.ini`**.
|
||||
|
||||
For a server with a public IP, `scripts/install.sh` does everything (see the README). Behind frp
|
||||
it needs a few changes, because:
|
||||
|
||||
- **HTTPS can't be set up by the installer.** DNS points at the VPS, not at this server, so
|
||||
`certbot --nginx` can't work the usual way. HTTPS ends either on the VPS or in `frpc`.
|
||||
- **There are more proxies in the way.** The app must be told which of them to trust, or it sees
|
||||
every visitor as `127.0.0.1` (rate limiting then treats everyone as one person, and PayFast
|
||||
payment notifications fail their IP check).
|
||||
- **The app must know the site is HTTPS** even though nginx on this server only speaks HTTP;
|
||||
otherwise it builds `http://` links (event images, payment return URLs).
|
||||
|
||||
## 1. How the traffic flows
|
||||
|
||||
Pick the setup that matches how your other frp sites get HTTPS.
|
||||
|
||||
**A. HTTPS ends on the VPS** (nginx/Caddy on the VPS holds the certificate) — recommended if the
|
||||
VPS already does this for other sites:
|
||||
|
||||
```
|
||||
visitor ──https──▶ VPS nginx/Caddy ──http──▶ frps (vhost_http_port) ══tunnel══▶ frpc ──▶ nginx :80 ──▶ app
|
||||
```
|
||||
|
||||
**B. HTTPS ends in frpc** (the VPS only runs frps; the certificate lives on this server and
|
||||
frpc's `https2http` plugin uses it):
|
||||
|
||||
```
|
||||
visitor ──https──▶ frps (vhost_https_port) ══tunnel══▶ frpc https2http ──▶ nginx 127.0.0.1:8080 ──▶ app
|
||||
visitor ──http───▶ frps (vhost_http_port) ══tunnel══▶ frpc ──▶ nginx :80 (certificate check + redirect to https)
|
||||
```
|
||||
|
||||
On this server, nginx sends `/api`, `/uploads`, `/health` and `/docs` to the backend (port 5000)
|
||||
and everything else to the Next.js site (port 3000), the same as a normal install.
|
||||
|
||||
## 2. Before you start
|
||||
|
||||
- Ubuntu 22.04 or 24.04 on this server, with `frpc` already installed and connected (it runs as
|
||||
a systemd service, usually `frpc.service`).
|
||||
- The site's DNS record (e.g. `events.yourchurch.org`) points at the **VPS**.
|
||||
- On the VPS, `frps.ini` has `vhost_http_port` set (and `vhost_https_port` for setup B). Note
|
||||
the values; you need them below.
|
||||
- The licence key for this site from the Cross Events Hub.
|
||||
|
||||
## 3. Install the app
|
||||
|
||||
On this server, as root, run the installer **without** its certificate step:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scripts/install.sh
|
||||
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
|
||||
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
|
||||
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
|
||||
below to match.
|
||||
|
||||
### Switch the URLs to https
|
||||
|
||||
With `--no-ssl` the installer writes `http://` addresses. Change them to `https://`:
|
||||
|
||||
```bash
|
||||
cd /opt/hope-events
|
||||
sudo sed -i 's#=http://#=https://#' backend/.env frontend/.env.production
|
||||
grep -E 'URL=' backend/.env frontend/.env.production # all should now start with https://
|
||||
```
|
||||
|
||||
While `backend/.env` is open, set which proxies to trust (all of them run on loopback in both
|
||||
setups; see [step 7](#7-check-that-visitor-addresses-come-through) to confirm):
|
||||
|
||||
```bash
|
||||
sudo sed -i 's#^TRUST_PROXY=.*#TRUST_PROXY=loopback#' backend/.env
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_API_URL` is baked in when the site is built, so rebuild the frontend and restart:
|
||||
|
||||
```bash
|
||||
sudo -u hopeevents -H bash -lc 'cd /opt/hope-events/frontend && npm run build'
|
||||
sudo -u hopeevents -H bash -lc 'cd /opt/hope-events && pm2 reload ecosystem.config.js --update-env'
|
||||
```
|
||||
|
||||
Keep `JWT_SECRET` in `backend/.env` as it is: it also encrypts the secrets stored in the
|
||||
database (SMTP, payment keys).
|
||||
|
||||
## 4. nginx on this server
|
||||
|
||||
The installer wrote `/etc/nginx/sites-available/hope-events`. Replace it with the version for
|
||||
your setup, changing the domain (and the ports if you changed them).
|
||||
|
||||
### Setup A (HTTPS on the VPS)
|
||||
|
||||
```nginx
|
||||
# Shows the forwarded-for chain in the access log (see step 7). Safe to keep.
|
||||
log_format hope_proxy '$remote_addr xff="$http_x_forwarded_for" "$request" $status';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name events.yourchurch.org;
|
||||
client_max_body_size 25m;
|
||||
access_log /var/log/nginx/hope-events.access.log hope_proxy;
|
||||
|
||||
# Visitors only reach this through the VPS over HTTPS, so tell the app so.
|
||||
location ~ ^/(api|uploads|health|docs)(/|$) {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The VPS nginx/Caddy must redirect plain HTTP to HTTPS and pass the `Host` header through to frps
|
||||
unchanged (frps picks the site by its host name). For nginx on the VPS, roughly:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name events.yourchurch.org;
|
||||
# ssl_certificate / ssl_certificate_key from certbot on the VPS
|
||||
client_max_body_size 25m;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080; # frps vhost_http_port
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setup B (HTTPS in frpc)
|
||||
|
||||
Port 80 only answers certificate checks and redirects everything else; the real site is on
|
||||
`127.0.0.1:8080`, which only frpc's `https2http` plugin reaches.
|
||||
|
||||
```nginx
|
||||
log_format hope_proxy '$remote_addr xff="$http_x_forwarded_for" "$request" $status';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name events.yourchurch.org;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8080;
|
||||
server_name events.yourchurch.org;
|
||||
client_max_body_size 25m;
|
||||
access_log /var/log/nginx/hope-events.access.log hope_proxy;
|
||||
|
||||
location ~ ^/(api|uploads|health|docs)(/|$) {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, for either setup:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/certbot # setup B only
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 5. frpc: `/etc/frp/frpc.ini`
|
||||
|
||||
Back it up first, then **add** a section for the site below the existing ones. Leave `[common]`
|
||||
(server address, port, token) and the other sections as they are. Section names must be unique
|
||||
across everything connected to your frps.
|
||||
|
||||
```bash
|
||||
sudo cp /etc/frp/frpc.ini /etc/frp/frpc.ini.bak-$(date +%F)
|
||||
sudo nano /etc/frp/frpc.ini
|
||||
```
|
||||
|
||||
### Setup A
|
||||
|
||||
```ini
|
||||
[hope-events-http]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 80
|
||||
custom_domains = events.yourchurch.org
|
||||
```
|
||||
|
||||
### Setup B
|
||||
|
||||
```ini
|
||||
# Plain HTTP: certificate checks and the redirect to https
|
||||
[hope-events-http]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 80
|
||||
custom_domains = events.yourchurch.org
|
||||
|
||||
# HTTPS: frpc holds the certificate and passes plain HTTP to nginx on 127.0.0.1:8080
|
||||
[hope-events-https]
|
||||
type = https
|
||||
custom_domains = events.yourchurch.org
|
||||
plugin = https2http
|
||||
plugin_local_addr = 127.0.0.1:8080
|
||||
plugin_crt_path = /etc/letsencrypt/live/events.yourchurch.org/fullchain.pem
|
||||
plugin_key_path = /etc/letsencrypt/live/events.yourchurch.org/privkey.pem
|
||||
plugin_header_X-Forwarded-Proto = https
|
||||
```
|
||||
|
||||
Restart frpc and check it registered the new sections without errors:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart frpc
|
||||
sudo journalctl -u frpc -n 30 --no-pager # look for "start proxy success" for each section
|
||||
```
|
||||
|
||||
> Newer frp releases (0.52+) prefer `frpc.toml`, but still read `.ini`. Don't mix the two: if
|
||||
> your service already uses `frpc.ini`, keep adding to it.
|
||||
|
||||
### Certificate (setup B only)
|
||||
|
||||
Request it through the tunnel with the webroot method (not `--nginx`, which would rewrite the
|
||||
nginx config), then have renewals restart frpc so it loads the new certificate:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d events.yourchurch.org \
|
||||
-m you@yourchurch.org --agree-tos --non-interactive
|
||||
echo 'systemctl restart frpc' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/restart-frpc.sh
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-frpc.sh
|
||||
sudo systemctl restart frpc
|
||||
```
|
||||
|
||||
frpc needs to be able to read the certificate files (it does when it runs as root, the usual
|
||||
setup). If the `[hope-events-https]` section fails to start, check `journalctl -u frpc`.
|
||||
|
||||
## 6. Finish in the browser
|
||||
|
||||
1. Open `https://events.yourchurch.org/health` — it should answer with the version.
|
||||
2. Open `https://events.yourchurch.org/setup`: create the admin account.
|
||||
3. **Site settings → Licence**: enter the licence key to connect to the Hub. The site checks in
|
||||
every 15 minutes from then on (outgoing HTTPS to `events-hub.crosscode.co.za`, which frp
|
||||
doesn't affect).
|
||||
4. Configure SMTP, payments (Yoco/PayFast webhooks use the `https://` `BACKEND_URL`), branding,
|
||||
etc. in Site settings.
|
||||
|
||||
## 7. Check that visitor addresses come through
|
||||
|
||||
Open the site from your phone (on mobile data, so it isn't your own network), then on the
|
||||
server:
|
||||
|
||||
```bash
|
||||
sudo tail -n 5 /var/log/nginx/hope-events.access.log
|
||||
```
|
||||
|
||||
The `xff="…"` part is what the app sees, with nginx's own entry added at the end:
|
||||
|
||||
- `xff="<your phone's IP>, 127.0.0.1"` (only `127.0.0.1` after the real address): good,
|
||||
`TRUST_PROXY=loopback` is right.
|
||||
- `xff="<your phone's IP>, 203.0.113.10"` (a non-local address after it, e.g. the VPS's): add
|
||||
that address: `TRUST_PROXY=loopback,203.0.113.10`, then
|
||||
`pm2 reload ecosystem.config.js --update-env`.
|
||||
- Your phone's IP doesn't appear at all: frp isn't passing the visitor's address on (can happen
|
||||
with setup B on some frp versions). Rate limiting and the PayFast IP check will then misbehave.
|
||||
Fix it by moving TLS to the VPS (setup A), or with frp's `proxy_protocol_version = v2` on the
|
||||
frpc section plus nginx's `listen … proxy_protocol` and `set_real_ip_from 127.0.0.1;
|
||||
real_ip_header proxy_protocol;`.
|
||||
|
||||
The backend logs the setting it uses at startup: `pm2 logs hope-events-backend | grep trust`.
|
||||
|
||||
## 8. Updating
|
||||
|
||||
Nothing about frp changes for updates. On the server:
|
||||
|
||||
```bash
|
||||
cd /opt/hope-events
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main
|
||||
```
|
||||
|
||||
It backs up the database, checks out the release, installs, migrates, builds and reloads PM2,
|
||||
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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| --- | --- |
|
||||
| frps shows "404 Not Found" page | The `custom_domains` value doesn't match the address typed, or frpc didn't start that section (`journalctl -u frpc`). |
|
||||
| 502 Bad Gateway | nginx is up but the app isn't: `sudo -u hopeevents pm2 ls` and `pm2 logs`. |
|
||||
| Site loads but login/API calls fail (CORS) | `FRONTEND_URL` in `backend/.env` is still `http://`, or the frontend wasn't rebuilt after changing `NEXT_PUBLIC_API_URL`. |
|
||||
| Images or links use `http://` (browser "mixed content" warnings) | nginx isn't sending `X-Forwarded-Proto https`, or `TRUST_PROXY` isn't set. |
|
||||
| "Too many requests" for everyone at once | All visitors look like one address: see step 7. |
|
||||
| PayFast payments stay pending | Same as above: the PayFast notification's IP check sees the proxy, not PayFast. |
|
||||
| Uploads over ~1 MB fail | `client_max_body_size` missing on this server's nginx or the VPS's. |
|
||||
| Redirect loop (setup B) | The site's `server` block is on port 80 instead of `127.0.0.1:8080`, or `plugin_local_addr` points at port 80. |
|
||||
+212
-17
@@ -1,4 +1,4 @@
|
||||
# Hope Events — Feature Overview
|
||||
# Cross Events — Feature Overview
|
||||
|
||||
A complete map of what the platform does, grouped by who uses it. For setup and
|
||||
deployment see [README.md](./README.md); for the API see
|
||||
@@ -29,12 +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. [Accounts & security](#accounts--security)
|
||||
17. [Privacy (POPIA)](#privacy-popia)
|
||||
18. [Help system](#help-system)
|
||||
19. [Background jobs](#background-jobs)
|
||||
20. [Operations](#operations)
|
||||
21. [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)
|
||||
|
||||
---
|
||||
|
||||
@@ -87,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
|
||||
@@ -393,6 +405,10 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
with event and section filters.
|
||||
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -493,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
|
||||
@@ -511,6 +528,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
| Tab | Contains |
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
@@ -523,6 +541,139 @@ 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
|
||||
default — single-campus churches never see campus fields).
|
||||
|
||||
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills in
|
||||
its default location. Moving an event to another campus is in the audit log.
|
||||
- **Public site:** a campus filter on the events page, remembered per visitor and starting on their
|
||||
home campus; a campus shows its own events plus church-wide ones. The campus shows on event cards,
|
||||
the event page and ticket PDFs. Each campus has a page at `/campus/<slug>` (also in the sitemap).
|
||||
- **People** choose a home campus on their profile.
|
||||
- **Reports and dashboards:** a campus filter on Reports (every report and the funnel) and on the
|
||||
admin/supervisor dashboards' month-over-month tiles, with a church-wide total.
|
||||
- **Messages:** "Everyone at a campus" in Email and WhatsApp broadcasts — home campus, plus (ticked)
|
||||
people who registered for its events in the last 12 months. News consent still applies.
|
||||
- **Daily summary:** campus recipients get the summary of their campus's events.
|
||||
- **Turning it off** hides everything campus-related; nothing is deleted. A campus with events can't
|
||||
be deleted, only archived.
|
||||
- **Staff for some campuses** (Admin → Users → "Limit to campuses"): a supervisor or staff member
|
||||
sees only their campuses' events plus events for all campuses — every list, report, dashboard
|
||||
and scanner follows it. For church-wide events they can scan, register people and take payments
|
||||
at the door, but not change, cash up or refund them. The site banner, payment reconciliation and
|
||||
donations not linked to an event are for admins and unscoped supervisors; broadcasts only reach
|
||||
people at their campuses or their events' attendees; coupons only for their campuses' events.
|
||||
Another campus's records answer "not found"; the scanner says which campus a ticket is for.
|
||||
Admins are never limited; limits are ignored while campuses are off.
|
||||
|
||||
---
|
||||
|
||||
## Kids check-in
|
||||
|
||||
Secure check-in and pickup for children's ministry, run from a staff desk and fully digital — no
|
||||
label printers.
|
||||
|
||||
- **Setup** (Supervisor → Kids check-in setup, `/dashboard/supervisor/kids`): per event, a
|
||||
**kids ticket** (a main ticket covering the whole event — "Add a free Kids check-in ticket"
|
||||
makes one) and **rooms** (an allocation set; each room has an age range, capacity and children
|
||||
per leader), then switch it on. Weekly kids church = one event with a session per Sunday.
|
||||
- **Families** are kept between events: parents add their children under My account → **Kids**
|
||||
(`/dashboard/user/kids`) — date of birth, allergies, medical and custody notes — and their
|
||||
**authorised adults** (name, phone, relationship; no account needed). The page shows a family QR
|
||||
code for the desk.
|
||||
- **Desk** (`/dashboard/staff/kids`): find the family (name, phone or family QR) or add one, tick
|
||||
the children (room suggested from the age: the narrowest range that isn't full), check in. The
|
||||
family gets a **4-character pickup code** for the day or session (no 0/O/1/I), sent to the parents
|
||||
by WhatsApp/email and shown on their Kids page with a QR code; it can also go to an authorised
|
||||
adult. A **"write on the sticker"** panel shows first name, room, code and ALLERGY/MEDICAL in
|
||||
large print to copy onto a blank sticker.
|
||||
- **Check-out:** type or scan the code, see the children on it, the parents, the authorised adults
|
||||
and any custody notes, record who collected. Anyone with the code can collect. A **wrong code**
|
||||
is refused, raises an alert on the room screens and is audited; **without the code** needs who
|
||||
and why (audited).
|
||||
- **Room screen** (`/dashboard/staff/kids/rooms`): live cards per room — code, allergies, medical
|
||||
and custody notes, parents and authorised adults — "I'm on duty", counts and full/over-ratio
|
||||
warnings, **Call parent** (WhatsApp/email, shown on the parent's page), move to another room,
|
||||
check out without code, wrong-code alerts.
|
||||
- **Offline desk:** "Get ready for offline" downloads the families and 20 pickup codes reserved for
|
||||
the device. Offline, the desk checks in (no messages) and checks out children it checked in
|
||||
itself; "Sync" uploads everything and says if a family got another code elsewhere.
|
||||
- **Attendance:** each child gets a free place on the kids ticket under the parent the first time
|
||||
(registration, attendee, ticket), and each check-in is a ticket usage — so the attendance
|
||||
register, reports and campus scoping include them.
|
||||
- **Privacy:** allergies, medical and custody notes encrypted, never in messages; custody notes
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
@@ -578,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):
|
||||
|
||||
@@ -621,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
|
||||
@@ -639,9 +811,13 @@ 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`) |
|
||||
| `hub-heartbeat` — check in with the Cross Events Hub and pick up licence changes (only once connected) | Every 15 minutes (`HUB_HEARTBEAT_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
@@ -650,6 +826,24 @@ can't start, the same jobs run on in-process timers.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Install script** (`scripts/install.sh`) — a fresh Ubuntu server to a running site: Node 20,
|
||||
PostgreSQL, PM2, nginx, HTTPS, configuration, migrations and build; then `/setup`.
|
||||
- **Cross Events Hub** — every install connects with a licence key (`/setup`, or Site settings →
|
||||
Licence) and checks in every 15 minutes with its version, health and totals (no personal
|
||||
information; income as a range). Licences are signed tokens checked on the site, so a Hub outage
|
||||
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, 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
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
@@ -661,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).
|
||||
|
||||
---
|
||||
@@ -676,6 +868,8 @@ 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 |
|
||||
@@ -689,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`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hope Events Platform
|
||||
# Cross Events Platform
|
||||
|
||||
Hope Events is a full-stack event management platform that allows users to browse events, register for them, make payments, and manage their tickets.
|
||||
Cross Events is a full-stack event management platform that allows users to browse events, register for them, make payments, and manage their tickets.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -92,12 +92,32 @@ 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
|
||||
|
||||
### A new install
|
||||
|
||||
On a fresh Ubuntu 22.04/24.04 server, with the domain's DNS already pointing at it:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scripts/install.sh
|
||||
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
|
||||
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
|
||||
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
|
||||
to connect the site to the Cross Events Hub. Several installs can share a server with different
|
||||
`--name`, `--dir`, `--web-port` and `--api-port`. Run `--help` for all options.
|
||||
|
||||
Behind an frp tunnel (no public IP on the server)? See [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
### Updating
|
||||
|
||||
To update a running install, on the server in the repo folder:
|
||||
|
||||
- `bash scripts/deploy.sh` — the latest `main`
|
||||
|
||||
+23
-1
@@ -17,6 +17,7 @@ in-process settings cache TTL) without a server restart.
|
||||
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
|
||||
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
|
||||
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
|
||||
| `APP_TIMEZONE` (env) | `Africa/Johannesburg` | The site's time zone (an IANA name, e.g. `Europe/London`): background job schedules (e.g. the 07:00 daily summary), session dates and printed times. Also sent to the Hub in the heartbeat. Needs a backend restart. |
|
||||
|
||||
## Branding
|
||||
|
||||
@@ -84,6 +85,22 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## Campuses
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/campuses/settings` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
|
||||
|
||||
## Cross Events Hub (licence)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `HUB_URL` (env) | `https://events-hub.crosscode.co.za` | Where the Hub runs. |
|
||||
| `HUB_PUBLIC_KEY` (env) | The production key built into `backend/src/hub/publicKey.js` | Only for a local or test Hub. Licence tokens are checked with this key. |
|
||||
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job (plus one check-in 30 s after starting and after connecting). Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
|
||||
| `LICENCE_CHECK_DISABLED` (env) | `false` | `true` turns read-only mode off — **ignored when `NODE_ENV=production`**. For the end-to-end tests and local development without a Hub. |
|
||||
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "no licence", which is read-only). The heartbeat deletes the install and licence keys itself when the Hub answers `INSTALL_NOT_FOUND`. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
@@ -97,8 +114,13 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `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, 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 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `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. |
|
||||
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
# 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.2
|
||||
|
||||
- No changes for churches in this version — it adds demo data for showing Cross Events to other
|
||||
churches.
|
||||
|
||||
## 1.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
webhooks. If you haven't published your own version, your site shows the new text straight away.
|
||||
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
|
||||
built-in text, section by section, so you can take the new wording with one click and publish.
|
||||
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
|
||||
activate it, and anyone who never has is asked once after they next log in.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
|
||||
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
|
||||
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
|
||||
- Every message is signed so the other system can trust it, failures are tried again
|
||||
automatically, and a delivery log shows what was sent.
|
||||
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
|
||||
|
||||
## 1.47.4
|
||||
|
||||
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
|
||||
asked for, tells you when a newer one is out, and lets you ask for that one instead with one
|
||||
button.
|
||||
|
||||
## 1.47.3
|
||||
|
||||
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
|
||||
year's breakfasts. People buy it on the series page, pay once, and get a ticket for each event.
|
||||
- The money is shared out over the events in line with their normal ticket prices, so each event's
|
||||
cashup shows its part.
|
||||
- Set passes up on the Event series page. A pass stops selling when its first event starts.
|
||||
|
||||
## 1.47.2
|
||||
|
||||
- **Compare a series year on year.** A new report puts the events in a series side by side —
|
||||
registrations, tickets, income and how many came — with how much each went up or down from the
|
||||
one before. Open it from Reports, or with **Compare** on the Event series page.
|
||||
|
||||
## 1.47.1
|
||||
|
||||
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
|
||||
one page that shows what's coming up and what's been. Each event in a series links to the next one.
|
||||
- **Follow a series.** People can follow a series and get an email when the next event in it is
|
||||
out. It counts as news, so only people who chose to get news and announcements receive it.
|
||||
- A copied event stays in its series, and a series can have events at different campuses.
|
||||
|
||||
## 1.47.0
|
||||
|
||||
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
|
||||
and the editor opens with everything filled in: tickets and prices, early birds, sessions, the
|
||||
form, feedback questions, certificates and groups.
|
||||
- **Pick the new start date** and every other date moves with it — sessions, deadlines and
|
||||
early-bird dates included. Check each step, change what's different this time, and save.
|
||||
- Registrations, coupons, attachments, kids check-in and volunteer roles aren't copied.
|
||||
|
||||
## 1.46.2
|
||||
|
||||
- **See what's new before you update.** When a new version is out, the Licence page lists what it
|
||||
brings, so you can decide whether you want it.
|
||||
- **Changed your mind?** Withdraw an update request, and your provider is told not to update the site.
|
||||
- **Not now:** hide the "update available" reminder until the next version comes out. Updates that
|
||||
fix a security problem or a serious bug are marked in red and can't be put off.
|
||||
- **Notices from your provider**, such as planned maintenance, now show as a banner for site admins
|
||||
and supervisors.
|
||||
|
||||
## 1.46.1
|
||||
|
||||
- Behind-the-scenes maintenance. Nothing changes for you.
|
||||
|
||||
## 1.46.0
|
||||
|
||||
- **Volunteer rosters:** set up serving roles (Welcome, Parking, Kids, Sound…) per event or session.
|
||||
Anyone with an account can sign up under My account → Serving, or supervisors can assign people.
|
||||
- Reminders by WhatsApp or email the day before a shift.
|
||||
- **"Can't make it" swaps:** a volunteer asks for cover, and the first to accept takes the place.
|
||||
- Volunteers check in by scanning the event's QR code; kids-room volunteers count as leaders on duty.
|
||||
- Roster sheet with phone numbers and check-in times, and hours served per person.
|
||||
- Fixed: the free kids check-in ticket was offered on the public event page.
|
||||
|
||||
## 1.45.1
|
||||
|
||||
- The built-in privacy notice and terms of use are brought up to date for kids check-in, sessions,
|
||||
certificates and campuses. If you published your own privacy notice, review it under Admin →
|
||||
Privacy.
|
||||
|
||||
## 1.45.0
|
||||
|
||||
- **Kids check-in:** a secure check-in and pickup for children's ministry, run from a staff desk, with
|
||||
no label printers needed.
|
||||
- Parents keep their children's details (allergies, medical and custody notes) and authorised adults
|
||||
under My account → Kids.
|
||||
- Each check-in gives the family a pickup code by WhatsApp or email; children can only be collected
|
||||
with the code.
|
||||
- Rooms with age ranges, capacity and ratios, live room screens, and an offline desk mode.
|
||||
|
||||
## 1.44.0
|
||||
|
||||
- **Staff for some campuses:** supervisors and staff can be limited to certain campuses (Admin →
|
||||
Users). They then see only those campuses' events, plus events for all campuses, everywhere in the
|
||||
dashboard, reports and the scanner.
|
||||
- The scanner says which campus a ticket is for.
|
||||
|
||||
## 1.43.0
|
||||
|
||||
- **Campuses** for churches with more than one campus (Site settings → Campuses, off by default).
|
||||
- Events belong to one campus or to all campuses; the public events page has a campus filter and
|
||||
each campus gets its own page.
|
||||
- Reports, dashboards, broadcasts and the daily summary can be filtered by campus.
|
||||
- People can choose their home campus on their profile.
|
||||
|
||||
## 1.42.1
|
||||
|
||||
- Behind-the-scenes improvements to licence checking. Nothing changes for you.
|
||||
|
||||
## 1.42.0
|
||||
|
||||
- **Request update:** when a new version is out, press Request update on Site settings → Licence and
|
||||
your provider updates the site for you.
|
||||
|
||||
## 1.41.3
|
||||
|
||||
- The platform is now called **Cross Events**. Nothing else changes, and everyone stays signed in.
|
||||
|
||||
## 1.41.2
|
||||
|
||||
- Fixed: the Background jobs page showed last-run times two hours ahead.
|
||||
|
||||
## 1.41.1
|
||||
|
||||
- A site without a licence is now read-only until a licence key is connected.
|
||||
- Fixed: a site whose licence was released kept showing it as active.
|
||||
|
||||
## 1.41.0
|
||||
|
||||
- **Licences:** the site connects to Cross Events with a licence key (Site settings → Licence) and
|
||||
checks in every 15 minutes with its version and health — never anyone's personal information.
|
||||
- After a licence's end date there's a grace period with a reminder for site admins.
|
||||
- Site admins see when a newer version is available.
|
||||
|
||||
## 1.40.0
|
||||
|
||||
- **POPIA toolkit, part 3:** a privacy readiness checklist with a score, and links to fix each item.
|
||||
- **Retention settings:** choose how long personal information is kept. Inactive accounts are warned
|
||||
and then anonymised after 5 years (on by default).
|
||||
- **Breach register:** record security incidents, with draft messages for the people affected and
|
||||
for the Information Regulator.
|
||||
|
||||
## 1.39.0
|
||||
|
||||
- **POPIA toolkit, part 2:** Admin → Privacy (POPIA).
|
||||
- Edit and publish your own privacy notice, with numbered versions, and ask everyone to accept a new
|
||||
version when it changes significantly.
|
||||
- A register of the outside services that handle personal information, a consent log, and an access
|
||||
log of who downloaded personal information.
|
||||
|
||||
## 1.38.0
|
||||
|
||||
- **POPIA toolkit, part 1:** a Privacy page for everyone (My account → Privacy): choose whether to get
|
||||
news by email or WhatsApp, download your data, ask for a correction or delete your account.
|
||||
- Bulk messages are either **event updates** (to everyone booked) or **news** (only to people who
|
||||
opted in, with an unsubscribe link). Replying STOP on WhatsApp stops news.
|
||||
- Admins handle correction and deletion requests under Admin → Data requests.
|
||||
|
||||
## 1.37.4
|
||||
|
||||
- Behind-the-scenes improvements to updating the site. Nothing changes for you.
|
||||
|
||||
## 1.37.3
|
||||
|
||||
- Behind-the-scenes fix. Nothing changes for you.
|
||||
|
||||
## 1.37.2
|
||||
|
||||
- **Payment status checks:** online payments whose confirmation never arrived from PayFast are found
|
||||
and recorded automatically, with a "Check status" button under Payments → Reconcile.
|
||||
- A "Test connection" button for the PayFast settings.
|
||||
|
||||
## 1.37.1
|
||||
|
||||
- "Pay online" is no longer offered when online payments are off or the amount is under R15; people
|
||||
are told to pay at the event instead.
|
||||
|
||||
## 1.37.0
|
||||
|
||||
- **PayFast:** a second online payment provider (card, instant EFT and more), set up in Site
|
||||
settings → Payments.
|
||||
- When more than one provider is on, people choose how to pay.
|
||||
|
||||
## 1.36.0
|
||||
|
||||
- **Site settings → Payments:** turn online payment providers on or off and choose their order.
|
||||
- Online payments must be at least R15; smaller balances are paid at the door.
|
||||
|
||||
## 1.35.1
|
||||
|
||||
- **Waitlist per session** on events where people pick sessions.
|
||||
- "All sessions" tickets let people choose which sessions they'll come to.
|
||||
- At the door, walk-ins can be booked straight into today's session.
|
||||
- Several fixes for per-session tickets and stock limits.
|
||||
|
||||
## 1.35.0
|
||||
|
||||
- **Pick sessions:** a multi-session event can let people choose which sessions to come to — day
|
||||
passes for a conference, or a weekly drop-in class — with a price per session.
|
||||
- Each session can have its own capacity, and tickets only scan on their own session.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
- **Background jobs page** (Site settings → Background jobs): see every scheduled task, when it last
|
||||
ran, and run it now.
|
||||
- Scheduled tasks (reminders, summaries, backups) are more reliable and run on South African time.
|
||||
- Fixed: at the door, multi-day tickets showed as used up from the second session.
|
||||
|
||||
## 1.33.0
|
||||
|
||||
- **Multi-day events:** an event can run over several sessions (e.g. every Wednesday for 5 weeks);
|
||||
people register and pay once.
|
||||
- Check-in per session, optional reminders before each session, and late joining at a lower price.
|
||||
- **Attendance register** with ticks per session and Excel/PDF export.
|
||||
- **Completion certificates** with four designs, issued automatically, each with a QR code anyone can
|
||||
check.
|
||||
|
||||
## 1.32.0
|
||||
|
||||
- Event cards show an "Early bird" ribbon while early-bird pricing is running.
|
||||
|
||||
## 1.31.1
|
||||
|
||||
- A field for Google Search Console verification (Site settings → Organisation).
|
||||
|
||||
## 1.31.0
|
||||
|
||||
- The registration funnel recognises visits from Facebook, Instagram, Google and other sites without
|
||||
tagged links, and shows shares and QR code scans separately.
|
||||
- Events can appear in Google's event listings with their date, venue and tickets.
|
||||
|
||||
## 1.30.1
|
||||
|
||||
- All tickets — emailed, WhatsApped and printed — share one clean design, with bigger QR codes.
|
||||
|
||||
## 1.30.0
|
||||
|
||||
- People can add an optional **donation** when they register, paid together with their tickets.
|
||||
- Fixed: event pages with a waitlist could crash.
|
||||
|
||||
## 1.29.1
|
||||
|
||||
- The Coupon codes page is easier to use, including on phones, and hides codes that are off, expired
|
||||
or used up.
|
||||
|
||||
## 1.29.0
|
||||
|
||||
- Bundle deals come in four types: an exact set, pick any mix, buy X get Y free, and group discounts.
|
||||
|
||||
## 1.28.3
|
||||
|
||||
- Fixed: the bundle deals form didn't fit on the screen.
|
||||
|
||||
## 1.28.2
|
||||
|
||||
- The Bundle deals page has a new layout that works on phones.
|
||||
|
||||
## 1.28.1
|
||||
|
||||
- Bundle deals work at the door, in manual registration and on the self-service kiosk too.
|
||||
|
||||
## 1.28.0
|
||||
|
||||
- **Bundle deals:** sell a set of tickets together for one price, e.g. 2 adults + 3 kids for R220.
|
||||
They apply automatically, with no code, and the saving shows on invoices and receipts.
|
||||
|
||||
## 1.27.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.27.0
|
||||
|
||||
- **Cashups** record where each payment method's money ended up: in the bank, cash on hand, or still
|
||||
pending. Card fees and bank charges can be written off with a reason.
|
||||
- Pending money can be marked received later without reopening the event.
|
||||
- Every dashboard page has the same "Back to dashboard" link.
|
||||
|
||||
## 1.26.1
|
||||
|
||||
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
|
||||
|
||||
## 1.26.0
|
||||
|
||||
- Behind-the-scenes upgrades and security updates.
|
||||
- Fixed: the refund reason didn't show at the door.
|
||||
|
||||
## 1.25.1
|
||||
|
||||
- On phones, the cookie notice and the help button no longer hide each other or the menu.
|
||||
|
||||
## 1.25.0
|
||||
|
||||
- **Yoco settings** in Site settings: enter and check the secret key, and set up payment
|
||||
notifications with one click.
|
||||
|
||||
## 1.24.0
|
||||
|
||||
- **Ticket transfers** now need the new holder to accept, by a link sent to them.
|
||||
- Updated terms of use and privacy policy, with a full cookie policy and a "Cookie settings" link.
|
||||
- Fixed: activating an account didn't sign you in.
|
||||
|
||||
## 1.23.0
|
||||
|
||||
- **View as user:** admins can see the site exactly as someone else does, to help with problems.
|
||||
It's read-only, limited to 30 minutes and recorded in the audit log.
|
||||
|
||||
## 1.22.0
|
||||
|
||||
- **Registration funnel report:** see where people drop off between viewing an event and paying, by
|
||||
traffic source and device. Only visitors who allow it are counted.
|
||||
|
||||
## 1.21.0
|
||||
|
||||
- **Post-event feedback:** send a short survey after an event, with one reminder, and see the results
|
||||
in a Feedback summary report.
|
||||
- New form question types: star ratings and multiple choice.
|
||||
|
||||
## 1.20.0
|
||||
|
||||
- **Sponsor a spot:** people can pay for a place for someone who can't afford it, with an optional
|
||||
goal and progress bar.
|
||||
|
||||
## 1.19.0
|
||||
|
||||
- **Rooms & groups** for camps: put attendees into rooms, cabins, small groups or buses by drag and
|
||||
drop or automatically, with printable lists.
|
||||
- Events can ask each person's gender.
|
||||
|
||||
## 1.18.0
|
||||
|
||||
- **Ticket transfer:** hand a ticket to someone else before the event; the old QR code stops working.
|
||||
|
||||
## 1.17.0
|
||||
|
||||
- **Offline ticket scanning:** download an event's tickets to a device and keep scanning without a
|
||||
connection; scans sync when it's back online.
|
||||
|
||||
## 1.16.0
|
||||
|
||||
- **Waitlists** for sold-out tickets: when a place frees up, the next person is offered it
|
||||
automatically.
|
||||
|
||||
## 1.15.0
|
||||
|
||||
- **Parental consent** for youth events: parents give consent online, with optional medical and
|
||||
emergency details, before their child can be checked in.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
- **Named attendees:** when booking for several people, each person can be named and get their own
|
||||
ticket.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
- **Coupon codes:** percentage, fixed-amount or free codes, with limits on events, dates and uses.
|
||||
- Staff prices for tickets.
|
||||
- A Discounts report.
|
||||
|
||||
## 1.12.0
|
||||
|
||||
- Two-factor login is now required for admins and supervisors.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- **Two-factor login** for everyone who wants it: a code by email or WhatsApp after the password, and
|
||||
"Trust this device for 30 days".
|
||||
|
||||
## 1.10.6
|
||||
|
||||
- Security updates.
|
||||
|
||||
## 1.10.5
|
||||
|
||||
- Events that have started but not finished stay listed on the home and events pages.
|
||||
|
||||
## 1.10.4
|
||||
|
||||
- Fixed: "Add to calendar" didn't work.
|
||||
|
||||
## 1.10.3
|
||||
|
||||
- More admin actions are written to the audit log.
|
||||
|
||||
## 1.10.2
|
||||
|
||||
- Behind-the-scenes fix to error monitoring. Nothing changes for you.
|
||||
|
||||
## 1.10.1
|
||||
|
||||
- Security fixes for file uploads, and security updates.
|
||||
|
||||
## 1.10.0
|
||||
|
||||
- **Add to calendar** on event pages and in confirmation emails.
|
||||
- Better link previews and search engine listings for events.
|
||||
- **Nightly database backups**, which you can download from Site settings → Backups.
|
||||
- **Audit log** of refunds, donations, manual registrations, cancellations, event changes and
|
||||
settings changes.
|
||||
|
||||
## 1.9.5
|
||||
|
||||
- The site copes better with automated traffic probing for pages that don't exist.
|
||||
|
||||
## 1.9.4
|
||||
|
||||
- Behind-the-scenes server setting. Nothing changes for you.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
- People registered at the door or by staff get their account activation link straight away.
|
||||
- Fixed: the self-service kiosk's "Choose a password" didn't work.
|
||||
|
||||
## 1.9.2
|
||||
|
||||
- Contact-only events no longer get a daily summary email.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
- Fixed: the browser tab and home page showed the default name instead of your church's name.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
- Events have a **location**, with a "Directions" link and a map on the event and contact pages.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- **Contact-only events** (e.g. baptism) show a "Contact us" button instead of registration.
|
||||
- **Invoices and receipts** as branded PDFs with registration and payment emails, with a "pay now"
|
||||
link and QR code.
|
||||
- Fixed: buying more tickets after early bird ended re-priced the tickets already bought.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- **Brand colours:** primary, secondary and accent colours across the whole site and emails, a
|
||||
favicon, and a live preview. Colours can be suggested from your logo.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- Supervisors can unassign a donation that was applied to a registration.
|
||||
- Faster payments and dashboards.
|
||||
|
||||
## 1.5.4
|
||||
|
||||
- The home page shows "My Dashboard" to people who are signed in.
|
||||
|
||||
## 1.5.3
|
||||
|
||||
- Fixed: help popups could be taller than a phone screen.
|
||||
|
||||
## 1.5.2
|
||||
|
||||
- Dashboard tiles and charts fit better on phones.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- Fixed: scheduled WhatsApp messages were sent as emails.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- **Site redesign:** a new look across the whole site, new dashboards with trends and top events, and
|
||||
a "Need help?" button with a guide on every page.
|
||||
- A separate Contact page, and recent sign-ins on Profile & Security.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
- Fixes to how refunds and donations are counted in reports and cashups.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
- Reports fit better on phones.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
- **Reports redesigned:** filters that apply to every report, charts, and styled PDF and Excel
|
||||
exports, by email or WhatsApp too.
|
||||
- Cashups show who recorded each payment, with per-person cash counts.
|
||||
- A payment link placeholder for messages to attendees.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
- The self-service kiosk is safer with returning visitors' details, and asks before changing them.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- The self-service kiosk can find a returning visitor's account.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
- **Check-in at the door** by number of people, without scanning each QR code, and a confirmation
|
||||
message to the ticket holder.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- "Make payment" goes straight to paying the full balance.
|
||||
- Staff can record a payment straight after a manual registration.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- A payment history page in My account.
|
||||
- Registration status shows as a coloured badge.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- Closed and past events are tidied away in My Events.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- The first release.
|
||||
@@ -13,6 +13,10 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Time zone for background job schedules (e.g. the 07:00 daily summary), session dates
|
||||
# and printed times. An IANA name, e.g. Africa/Johannesburg or Europe/London.
|
||||
APP_TIMEZONE=Africa/Johannesburg
|
||||
|
||||
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||
@@ -71,11 +75,25 @@ 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
|
||||
# accounts are anonymised after 5 years by default (with a warning 30 days before).
|
||||
RETENTION_JOBS_ENABLED=true
|
||||
|
||||
# ─── Cross Events Hub (licence and health) ─────────────────────────────────────
|
||||
# Where the Hub runs. The site checks in every 15 minutes once connected (Site settings → Licence).
|
||||
# HUB_URL=https://events-hub.crosscode.co.za
|
||||
# For a local or test Hub only: its public key (the release has the production key built in).
|
||||
# HUB_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----"
|
||||
HUB_HEARTBEAT_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hope Events — API Documentation
|
||||
# Cross Events — API Documentation
|
||||
|
||||
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
|
||||
|
||||
@@ -44,6 +44,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Forms](#forms)
|
||||
- [Provider Transactions](#provider-transactions)
|
||||
- [WhatsApp Admin](#whatsapp-admin)
|
||||
- [Hub and licence](#hub-and-licence)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Settings](#settings)
|
||||
- [Setup](#setup)
|
||||
@@ -135,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 |
|
||||
@@ -169,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
|
||||
@@ -836,6 +842,190 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
---
|
||||
|
||||
## Hub and licence
|
||||
|
||||
The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
|
||||
|
||||
| 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, 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.
|
||||
|
||||
## Campuses
|
||||
|
||||
Multi-campus (`src/utils/campuses.js`), off by default. While off, every campus field and filter
|
||||
below is ignored and `GET /api/campuses` returns `{ enabled: false, campuses: [] }`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/campuses` | public | `{ enabled, campuses }` — active campuses in order |
|
||||
| GET | `/api/campuses/by-slug/:slug` | public | One active campus (its `/campus/<slug>` page); 404 when archived or off |
|
||||
| GET | `/api/campuses/admin` | admin | All campuses incl. archived, with `notifyRecipients` and `_count { events, homeOf }` |
|
||||
| PUT | `/api/campuses/settings` | admin | `{ enabled }` — audited as `campuses_toggled` (the `campuses_enabled` setting can't be changed through `PUT /api/settings`) |
|
||||
| POST | `/api/campuses` | admin | `{ name, slug?, address?, contactName?, contactEmail?, contactPhone?, colour? (#rrggbb), logoUrl?, defaultLocation? }`; 409 when the slug is taken |
|
||||
| PUT | `/api/campuses/:id` | admin | Same fields, plus `notifyRecipientIds` (staff only) and `archived` |
|
||||
| PUT | `/api/campuses/order` | admin | `{ ids: [...] }` |
|
||||
| DELETE | `/api/campuses/:id` | admin | Only without events — otherwise `409 CAMPUS_HAS_EVENTS` (archive instead) |
|
||||
| GET | `/api/campuses/:id/audience` | supervisor+ | `{ campus, users }` — home campus, plus `?registrants=true` people registered for its events in the last 12 months (not cancelled). Broadcasts send to these ids as usual, so news consent still applies |
|
||||
|
||||
**Campus-scoped staff (1.44.0):** a supervisor or staff member with campus limits
|
||||
(`PUT /api/users/:id { scopedCampusIds }`, admin; `GET /api/users/:id` returns `scopedCampuses`;
|
||||
`GET /api/users/profile` returns `campusScope`, null when unscoped or campuses are off) only sees
|
||||
their campuses' events plus church-wide ones on every staff/supervisor endpoint
|
||||
(`src/utils/campusScope.js`). Responses for out-of-scope records:
|
||||
- `404 { message: "Not found" }` — another campus's event or record (nothing leaks).
|
||||
- `403 { code: "OTHER_CAMPUS" }` — the scanner (`/api/tickets/scan-preview|qr|scan/:qrCode`), naming the ticket's campus.
|
||||
- `403 { code: "CHURCH_WIDE_EVENT" }` — changing, cashing up or refunding an event for all campuses.
|
||||
- `403 { code: "CAMPUS_SCOPED" }` — banner, payment reconciliation, open checkouts, unlinked donations, typed-in broadcast addresses or recipients outside their campuses.
|
||||
- `409 { code: "CAMPUS_HAS_SCOPED_STAFF" }` on `DELETE /api/campuses/:id` while staff are limited to it.
|
||||
|
||||
**Campus on other endpoints:**
|
||||
- Events carry `campusId` (null = all campuses) and `campus { id, name, slug, colour }`. `POST`/`PUT /api/events` take `campusId` (`''`/`null` = all campuses; an archived campus can stay but can't be newly chosen); moving an event is audited as `event_campus_changed`.
|
||||
- `GET /api/events?campus=<slug|id>` — that campus's events plus church-wide ones. `GET /api/events/all?campus=<id|slug|all>` — one campus, or church-wide only.
|
||||
- `GET /api/stats/overview?campus=<id|slug|all>`.
|
||||
- `PUT /api/users/profile` takes `homeCampusId`; `GET` returns it.
|
||||
|
||||
## Kids check-in
|
||||
|
||||
Kids check-in (1.45.0, `src/utils/kids.js`). An event has it when `kidsCheckIn` is true with a
|
||||
`kidsOptionId` (main ticket covering the whole event) and `kidsRoomSetId` (allocation set whose
|
||||
groups are the rooms; groups take `minAge`, `maxAge`, `ratio` on `POST /api/allocations/sets/:id/groups`
|
||||
and `PUT /api/allocations/groups/:id`). The **occasion** is today's session on a multi-session event,
|
||||
or the day (`YYYY-MM-DD`, `APP_TIMEZONE`) on a single-date one; pickup codes are per family per occasion.
|
||||
Allergies, medical and custody notes are stored encrypted. Staff routes are campus-scoped.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/kids/me` | user | `{ family, qrToken, codes, pages }` — my family (or null), live pickup codes (`{ code, qr, event, children }`) and pages from the last 24 h |
|
||||
| POST / PUT / DELETE | `/api/kids/me` | user | Start my family `{ name }` / rename it / leave it (the last parent leaving deletes it and its children) |
|
||||
| POST, PUT, DELETE | `/api/kids/me/children[/:id]` | user | `{ firstName, lastName?, dateOfBirth?, gender?, allergies?, medicalNotes?, custodyNotes? }`; custody changes audited as `kids_custody_notes_changed` |
|
||||
| POST, PUT, DELETE | `/api/kids/me/adults[/:id]` | user | Authorised adults `{ name, phone?, relationship? }` (up to 10) |
|
||||
| GET | `/api/kids/setup-events` | supervisor+ | Upcoming events for the setup page |
|
||||
| GET / PUT | `/api/kids/events/:eventId/settings` | supervisor+ | `{ enabled?, optionId?, roomSetId? }` — `'new'` makes a free "Kids check-in" main ticket or a "Kids rooms" set; switching on needs both. Audited as `kids_settings_updated`; PUT needs a campus the supervisor manages |
|
||||
| GET | `/api/kids/events` | staff+ | Events with kids check-in, from the last week on, with their sessions |
|
||||
| GET | `/api/kids/events/:eventId/board?sessionId=&log=1` | staff+ | `{ event, occasion, sessionId, rooms, checkIns, alerts }`. Rooms carry `children`, `leaders`, `leadersNeeded`, `full`, `overRatio`; check-ins carry `code`, allergies, medical and custody notes, the family's parents and adults, and `sticker`. `log=1` writes the access log (`kids_rooms`) |
|
||||
| GET | `/api/kids/events/:eventId/families?q=` | staff+ | Families by family, child or parent name, email or phone (min. 2 characters) |
|
||||
| POST | `/api/kids/events/:eventId/families` | staff+ | `{ name?, parent: { name, phone?, email? }, children? }` — the parent's account is matched by email/phone or created inactive; an existing family is reused |
|
||||
| GET | `/api/kids/events/:eventId/families/qr/:token` | staff+ | The family for a family QR (`FAMILY-<token>`) |
|
||||
| GET | `/api/kids/events/:eventId/families/:familyId` | staff+ | One family |
|
||||
| POST | `/api/kids/events/:eventId/families/:familyId/parents` | staff+ | Add a parent (409 if they're in another family) |
|
||||
| POST, PUT | `/api/kids/events/:eventId/families/:familyId/children[/:childId]` | staff+ | Add/change a child — custody notes only from supervisors and admins |
|
||||
| POST | `/api/kids/events/:eventId/families/:familyId/adults` | staff+ | Add an authorised adult |
|
||||
| POST | `/api/kids/events/:eventId/check-in` | staff+ | `{ familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }` → `201 { code, qr, occasion, family, checkIns: [{ id, name, firstName, room, already, sticker }] }`. Messages the parents (and the adult) |
|
||||
| GET | `/api/kids/events/:eventId/code/:code?sessionId=` | staff+ | `{ code, found, family, children }` for a pickup code (`KIDS-` prefix and case ignored) |
|
||||
| POST | `/api/kids/events/:eventId/check-out` | staff+ | `{ code, checkInIds?, collectedBy, roomId?, sessionId? }` → `{ checkedOut }`. `404 WRONG_CODE` (alert + audit `kids_wrong_code`) or `404 NOTHING_TO_COLLECT` |
|
||||
| POST | `/api/kids/check-ins/:id/override` | staff+ | `{ collectedBy, reason }` — check out without the code; audited as `kids_checkout_override` |
|
||||
| POST | `/api/kids/check-ins/:id/page` | staff+ | `{ message? }` → `{ sent: ['whatsapp'\|'email'], text }` — "Please come to the … room" |
|
||||
| PUT | `/api/kids/check-ins/:id/room` | staff+ | `{ roomId }` |
|
||||
| POST | `/api/kids/events/:eventId/duty` | staff+ | `{ roomId, on, sessionId? }` — "I'm on duty" (one room at a time) |
|
||||
| POST | `/api/kids/alerts/:id/resolve` | staff+ | Mark an alert dealt with |
|
||||
| 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 |
|
||||
|
||||
+21
-8
@@ -1,10 +1,19 @@
|
||||
# Demo data
|
||||
|
||||
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
|
||||
for showing the platform to other churches. It stages six upcoming events with
|
||||
registrations, payments, tickets and people, and ten completed events from the past year
|
||||
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
|
||||
check-in and cashup screen has something to show.
|
||||
The demo seeds replace everything in the database with a realistic set of demo data, for
|
||||
showing the platform to other churches. Pick the one that looks like the church you're
|
||||
showing it to:
|
||||
|
||||
| Command (repo root) | For | What's in it |
|
||||
|---|---|---|
|
||||
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
|
||||
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
|
||||
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
|
||||
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
|
||||
|
||||
`npm run demo:list` prints the same list. Kids church always has a session **today** with
|
||||
children checked in right now, so the kids desk and room screens have something live to
|
||||
show on any day.
|
||||
|
||||
> **This deletes real data.** Run it only against a database you're happy to wipe, such
|
||||
> as a dedicated demo deployment. It is not something to run on a live church site.
|
||||
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run set-demo # shows the database it will wipe and asks you to type "demo"
|
||||
npm run set-demo -- --yes # no prompt (for scripts)
|
||||
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
|
||||
npm run demo:ministry -- --yes # no prompt (for scripts)
|
||||
# or in backend/:
|
||||
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
|
||||
```
|
||||
|
||||
The campuses setting is switched on for `multicampus` and off for the others (site settings
|
||||
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
|
||||
|
||||
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
|
||||
for one run, set it in the environment first. A value already in the environment wins
|
||||
over `.env`:
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Hope Events — Backend API
|
||||
# Cross Events — Backend API
|
||||
|
||||
Node.js + Express REST API for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`).
|
||||
Node.js + Express REST API for the Cross Events platform used by Hope Family Church (`events.hopehenley.co.za`).
|
||||
|
||||
---
|
||||
|
||||
@@ -145,7 +145,7 @@ npm run prisma:generate
|
||||
|
||||
### Demo data
|
||||
|
||||
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.48.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.48.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.48.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_connected';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- 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_requested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campuses_toggled';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_archived';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_deleted';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'event_campus_changed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "campusId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "homeCampusId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Campus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"contactName" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"colour" TEXT,
|
||||
"logoUrl" TEXT,
|
||||
"defaultLocation" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Campus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_CampusNotifyRecipients" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_CampusNotifyRecipients_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Campus_slug_key" ON "Campus"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Campus_order_idx" ON "Campus"("order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_CampusNotifyRecipients_B_index" ON "_CampusNotifyRecipients"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_campusId_idx" ON "Event"("campusId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_homeCampusId_idx" ON "User"("homeCampusId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_homeCampusId_fkey" FOREIGN KEY ("homeCampusId") REFERENCES "Campus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "Campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_campus_scope_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_UserCampusScope" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_UserCampusScope_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_UserCampusScope_B_index" ON "_UserCampusScope"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,250 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KidsAlertKind" AS ENUM ('wrong_code', 'page_parent', 'override');
|
||||
|
||||
-- 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 'kids_settings_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_checkout_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_wrong_code';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_custody_notes_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_offline_sync';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AllocationGroup" ADD COLUMN "maxAge" INTEGER,
|
||||
ADD COLUMN "minAge" INTEGER,
|
||||
ADD COLUMN "ratio" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "kidsCheckIn" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "kidsOptionId" TEXT,
|
||||
ADD COLUMN "kidsRoomSetId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Family" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"qrToken" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Family_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FamilyMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FamilyMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Child" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"allergies" TEXT,
|
||||
"medicalNotes" TEXT,
|
||||
"custodyNotes" TEXT,
|
||||
"lastCheckInAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Child_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthorisedAdult" (
|
||||
"id" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"relationship" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuthorisedAdult_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsPickupCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"familyId" TEXT,
|
||||
"deviceId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "KidsPickupCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsCheckIn" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"childId" TEXT,
|
||||
"childName" TEXT NOT NULL,
|
||||
"roomId" TEXT,
|
||||
"pickupCodeId" TEXT NOT NULL,
|
||||
"attendeeId" TEXT,
|
||||
"ticketId" TEXT,
|
||||
"checkedInAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"checkedInById" TEXT,
|
||||
"checkedOutAt" TIMESTAMP(3),
|
||||
"checkedOutById" TEXT,
|
||||
"collectedBy" TEXT,
|
||||
"overrideReason" TEXT,
|
||||
"clientId" TEXT,
|
||||
"offline" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "KidsCheckIn_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsAlert" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"kind" "KidsAlertKind" NOT NULL,
|
||||
"roomId" TEXT,
|
||||
"checkInId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "KidsAlert_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "KidsRoomDuty" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"occasion" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "KidsRoomDuty_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Family_qrToken_key" ON "Family"("qrToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FamilyMember_userId_key" ON "FamilyMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FamilyMember_familyId_idx" ON "FamilyMember"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Child_familyId_idx" ON "Child"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Child_lastCheckInAt_idx" ON "Child"("lastCheckInAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthorisedAdult_familyId_idx" ON "AuthorisedAdult"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsPickupCode_eventId_occasion_deviceId_idx" ON "KidsPickupCode"("eventId", "occasion", "deviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_code_key" ON "KidsPickupCode"("eventId", "occasion", "code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_familyId_key" ON "KidsPickupCode"("eventId", "occasion", "familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsCheckIn_clientId_key" ON "KidsCheckIn"("clientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_eventId_occasion_idx" ON "KidsCheckIn"("eventId", "occasion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_roomId_idx" ON "KidsCheckIn"("roomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsCheckIn_pickupCodeId_idx" ON "KidsCheckIn"("pickupCodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KidsCheckIn_eventId_occasion_childId_key" ON "KidsCheckIn"("eventId", "occasion", "childId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsAlert_eventId_occasion_createdAt_idx" ON "KidsAlert"("eventId", "occasion", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsAlert_checkInId_idx" ON "KidsAlert"("checkInId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsRoomDuty_roomId_occasion_idx" ON "KidsRoomDuty"("roomId", "occasion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "KidsRoomDuty_userId_idx" ON "KidsRoomDuty"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Child" ADD CONSTRAINT "Child_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuthorisedAdult" ADD CONSTRAINT "AuthorisedAdult_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_childId_fkey" FOREIGN KEY ("childId") REFERENCES "Child"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_pickupCodeId_fkey" FOREIGN KEY ("pickupCodeId") REFERENCES "KidsPickupCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedOutById_fkey" FOREIGN KEY ("checkedOutById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_checkInId_fkey" FOREIGN KEY ("checkInId") REFERENCES "KidsCheckIn"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
|
||||
@@ -141,6 +141,62 @@ model User {
|
||||
noticeAcceptances NoticeAcceptance[]
|
||||
dataAccessLogs DataAccessLog[]
|
||||
breachesRecorded SecurityBreach[]
|
||||
|
||||
// Multi-campus (only used while Site settings → Campuses is on).
|
||||
homeCampusId String?
|
||||
homeCampus Campus? @relation("UserHomeCampus", fields: [homeCampusId], references: [id], onDelete: SetNull)
|
||||
campusSummaries Campus[] @relation("CampusNotifyRecipients")
|
||||
// Campus-scoped staff (1.44.0): a supervisor/staff member limited to these campuses (their
|
||||
// events plus events for all campuses). Empty = unscoped. Ignored for admins and while
|
||||
// campuses are off (utils/campusScope.js).
|
||||
scopedCampuses Campus[] @relation("UserCampusScope")
|
||||
|
||||
// Kids check-in (1.45.0): the family this person is a parent in (at most one).
|
||||
familyMembership FamilyMember?
|
||||
kidsCheckInsDone KidsCheckIn[] @relation("KidsCheckInBy")
|
||||
kidsCheckOutsDone KidsCheckIn[] @relation("KidsCheckOutBy")
|
||||
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])
|
||||
}
|
||||
|
||||
// A campus of the church (Site settings → Campuses, off by default). Events belong to one
|
||||
// campus, or to all campuses when Event.campusId is null. Archived instead of deleted once
|
||||
// it has events.
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
contactName String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
// Accent colour (#rrggbb) and logo shown with the campus on the public site.
|
||||
colour String?
|
||||
logoUrl String?
|
||||
// Pre-fills Event.location when an event is created for this campus.
|
||||
defaultLocation String?
|
||||
order Int @default(0)
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events Event[]
|
||||
homeOf User[] @relation("UserHomeCampus")
|
||||
// Get the daily summary for this campus's events (on top of each event's own recipients).
|
||||
notifyRecipients User[] @relation("CampusNotifyRecipients")
|
||||
scopedStaff User[] @relation("UserCampusScope")
|
||||
|
||||
@@index([order])
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -187,6 +243,9 @@ model Event {
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
// Multi-campus: null = all campuses (also every event while campuses are off).
|
||||
campusId String?
|
||||
campus Campus? @relation(fields: [campusId], references: [id], onDelete: Restrict)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
@@ -243,9 +302,128 @@ model Event {
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
|
||||
// Kids check-in (1.45.0): children are checked in at the kids desk instead of buying
|
||||
// tickets. Each child gets a free place on `kidsOptionId` (a main-ticket option of this
|
||||
// event) the first time, so they show on the attendance register; rooms are the groups of
|
||||
// the allocation set `kidsRoomSetId`. Plain ids (checked in utils/kids.js), not relations.
|
||||
kidsCheckIn Boolean @default(false)
|
||||
kidsOptionId String?
|
||||
kidsRoomSetId String?
|
||||
kidsPickupCodes KidsPickupCode[]
|
||||
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 {
|
||||
@@ -269,6 +447,7 @@ model EventOption {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -305,6 +484,7 @@ model OptionVariant {
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
seasonPassItems SeasonPassItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -335,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])
|
||||
@@ -424,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])
|
||||
@@ -602,6 +791,33 @@ enum AdminAuditAction {
|
||||
retention_run
|
||||
breach_recorded
|
||||
breach_updated
|
||||
hub_connected
|
||||
hub_update_requested
|
||||
hub_update_request_withdrawn
|
||||
hub_update_snoozed
|
||||
hub_update_unsnoozed
|
||||
campuses_toggled
|
||||
campus_created
|
||||
campus_updated
|
||||
campus_archived
|
||||
campus_deleted
|
||||
event_campus_changed
|
||||
user_campus_scope_changed
|
||||
kids_settings_updated
|
||||
kids_checkout_override
|
||||
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
|
||||
@@ -1275,11 +1491,20 @@ model AllocationGroup {
|
||||
gender String?
|
||||
leaderUserId String?
|
||||
order Int @default(0)
|
||||
// Kids rooms (1.45.0): the ages the room is for (whole years, inclusive) and how many
|
||||
// children one leader may look after. All optional.
|
||||
minAge Int?
|
||||
maxAge Int?
|
||||
ratio Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||||
assignments AllocationAssignment[]
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
kidsDuties KidsRoomDuty[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
@@ -1555,6 +1780,7 @@ model EventSession {
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
@@ -1776,3 +2002,297 @@ model SecurityBreach {
|
||||
|
||||
@@index([status, discoveredAt])
|
||||
}
|
||||
|
||||
// ─── Kids check-in (1.45.0) ───────────────────────────────────────────────────
|
||||
|
||||
// A household whose children are checked in at the kids desk. Kept between events (weekly
|
||||
// kids church). `qrToken` is what the parent's family QR in My account → Kids holds.
|
||||
model Family {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
qrToken String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
members FamilyMember[]
|
||||
children Child[]
|
||||
adults AuthorisedAdult[]
|
||||
codes KidsPickupCode[]
|
||||
}
|
||||
|
||||
// A parent account in a family. A person is a parent in one family at most.
|
||||
model FamilyMember {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
userId String @unique
|
||||
createdAt DateTime @default(now())
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
// A child in a family. allergies, medicalNotes and custodyNotes are encrypted
|
||||
// (utils/encryption.js). Deleted by the retention job after months without a check-in.
|
||||
model Child {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
firstName String
|
||||
lastName String?
|
||||
dateOfBirth DateTime?
|
||||
gender String?
|
||||
allergies String?
|
||||
medicalNotes String?
|
||||
custodyNotes String?
|
||||
lastCheckInAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
checkIns KidsCheckIn[]
|
||||
|
||||
@@index([familyId])
|
||||
@@index([lastCheckInAt])
|
||||
}
|
||||
|
||||
// Someone the family allows to collect their children, who needn't have an account (a
|
||||
// grandparent, a friend). Anyone with the pickup code can collect; this list is what the
|
||||
// room leader checks the person against, and who the code can be sent to.
|
||||
model AuthorisedAdult {
|
||||
id String @id @default(uuid())
|
||||
familyId String
|
||||
name String
|
||||
phone String?
|
||||
relationship String?
|
||||
createdAt DateTime @default(now())
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
// A pickup code: one per family per occasion (a session, or the day on a single-date event),
|
||||
// shared by the children checked in together. Codes for offline desks are reserved ahead with
|
||||
// `deviceId` and no family, and claimed when the desk syncs.
|
||||
model KidsPickupCode {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
code String
|
||||
familyId String?
|
||||
deviceId String?
|
||||
createdAt DateTime @default(now())
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||
checkIns KidsCheckIn[]
|
||||
|
||||
@@unique([eventId, occasion, code])
|
||||
@@unique([eventId, occasion, familyId])
|
||||
@@index([eventId, occasion, deviceId])
|
||||
}
|
||||
|
||||
// One child checked in to a room for an occasion. childId is cleared when the child's profile
|
||||
// is deleted; the attendance (attendee, ticket, ticket usage) stays.
|
||||
model KidsCheckIn {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
sessionId String?
|
||||
childId String?
|
||||
childName String
|
||||
roomId String?
|
||||
pickupCodeId String
|
||||
attendeeId String?
|
||||
ticketId String?
|
||||
checkedInAt DateTime @default(now())
|
||||
checkedInById String?
|
||||
checkedOutAt DateTime?
|
||||
checkedOutById String?
|
||||
collectedBy String?
|
||||
overrideReason String?
|
||||
// Offline desks: the device's id for the check-in (makes syncing idempotent).
|
||||
clientId String? @unique
|
||||
offline Boolean @default(false)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
child Child? @relation(fields: [childId], references: [id], onDelete: SetNull)
|
||||
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
|
||||
pickupCode KidsPickupCode @relation(fields: [pickupCodeId], references: [id], onDelete: Cascade)
|
||||
checkedInBy User? @relation("KidsCheckInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
|
||||
checkedOutBy User? @relation("KidsCheckOutBy", fields: [checkedOutById], references: [id], onDelete: SetNull)
|
||||
alerts KidsAlert[]
|
||||
|
||||
@@unique([eventId, occasion, childId])
|
||||
@@index([eventId, occasion])
|
||||
@@index([roomId])
|
||||
@@index([pickupCodeId])
|
||||
}
|
||||
|
||||
enum KidsAlertKind {
|
||||
wrong_code
|
||||
page_parent
|
||||
override
|
||||
}
|
||||
|
||||
// Something the room needs to see: a wrong pickup code, a parent paged, an override.
|
||||
model KidsAlert {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
occasion String
|
||||
kind KidsAlertKind
|
||||
roomId String?
|
||||
checkInId String?
|
||||
message String
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
|
||||
checkIn KidsCheckIn? @relation(fields: [checkInId], references: [id], onDelete: SetNull)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, occasion, createdAt])
|
||||
@@index([checkInId])
|
||||
}
|
||||
|
||||
// A leader on duty in a kids room for an occasion ("I'm on duty"), for the ratio.
|
||||
model KidsRoomDuty {
|
||||
id String @id @default(uuid())
|
||||
roomId String
|
||||
occasion String
|
||||
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])
|
||||
}
|
||||
|
||||
+598
-19
@@ -2,9 +2,19 @@
|
||||
* set-demo.js
|
||||
*
|
||||
* Replaces the database contents with demo data for showing the system to other
|
||||
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
|
||||
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
|
||||
* completed events from the past year with door sales, check-ins, feedback and cashups.
|
||||
* churches. Four profiles, for different kinds of church (see PROFILES below, or
|
||||
* `npm run demo:list`):
|
||||
*
|
||||
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
|
||||
* events Events and conferences (the original demo, the default): six upcoming events
|
||||
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
|
||||
* analytics, and ten completed events with door sales, feedback and cashups.
|
||||
* ministry One campus with weekly ministry: a multi-session course (attendance and
|
||||
* certificates), a "pick your days" conference, kids church with kids check-in,
|
||||
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
|
||||
* multicampus Three campuses: events per campus and church-wide, home campuses,
|
||||
* campus-limited staff, a series across campuses, kids check-in and rosters at two
|
||||
* campuses.
|
||||
*
|
||||
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
|
||||
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
|
||||
@@ -13,10 +23,14 @@
|
||||
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
|
||||
* have no phone numbers, so later reminders can't reach a real person.
|
||||
*
|
||||
* Usage:
|
||||
* cd backend
|
||||
* npm run set-demo (asks for confirmation)
|
||||
* npm run set-demo -- --yes (no prompt)
|
||||
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
|
||||
* backend afterwards so it picks the setting up.
|
||||
*
|
||||
* Usage (from the repo root):
|
||||
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
|
||||
* npm run demo:list (what each one has)
|
||||
* or in backend/:
|
||||
* npm run set-demo -- --profile=ministry --yes (no prompt)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -38,6 +52,8 @@ const prisma = createPrismaClient();
|
||||
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
|
||||
const DEMO_PASSWORD = 'Demo@1234';
|
||||
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
|
||||
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
|
||||
const LIST_ONLY = process.argv.includes('--list');
|
||||
|
||||
// Not truncated: migrations, site settings, users (all but the admin are deleted
|
||||
// separately) and the admin's own account-security rows.
|
||||
@@ -161,13 +177,16 @@ const add = (model, row) => {
|
||||
};
|
||||
|
||||
const INSERT_ORDER = [
|
||||
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
|
||||
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
|
||||
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
|
||||
'ticketUsage', 'couponRedemption', 'registrationBundle',
|
||||
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
|
||||
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
|
||||
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
|
||||
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
|
||||
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
|
||||
];
|
||||
|
||||
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
|
||||
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
|
||||
quantity: l.quantity,
|
||||
priceSnapshot: price,
|
||||
appliedTierId: tier ? tier.id : null,
|
||||
// "Pick sessions" events: a per-session line.
|
||||
sessionId: l.sessionId || null,
|
||||
});
|
||||
add('registrationOptionTranche', {
|
||||
id: uuidv4(),
|
||||
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
|
||||
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
|
||||
*/
|
||||
function buildPastEvent(people, spec) {
|
||||
// Each profile has its own selection of past events (`always`: built by a profile's extras).
|
||||
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
|
||||
const { start } = spec;
|
||||
const event = addEvent({
|
||||
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
startDate: start,
|
||||
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Profiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
const UPCOMING = {
|
||||
mensBreakfast: buildMensBreakfast,
|
||||
familyFunDay: buildFamilyFunDay,
|
||||
womensConference: buildWomensConference,
|
||||
youthCamp: buildYouthCamp,
|
||||
carols: buildCarols,
|
||||
marriageWeekend: buildMarriageWeekend,
|
||||
};
|
||||
|
||||
/**
|
||||
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
|
||||
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
|
||||
* campuses: three campuses, switched on.
|
||||
*/
|
||||
const PROFILES = {
|
||||
small: {
|
||||
title: 'Small church — one congregation, the essentials',
|
||||
about: [
|
||||
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
|
||||
'Four past events with check-ins and cashups (one left open to cash up live).',
|
||||
'No campuses, kids check-in, volunteer rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
|
||||
extras: [],
|
||||
},
|
||||
events: {
|
||||
title: 'Events & conferences — the full events demo (default)',
|
||||
about: [
|
||||
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
|
||||
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
|
||||
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
|
||||
'One campus; no kids check-in, rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: Object.keys(UPCOMING),
|
||||
past: null,
|
||||
extras: [],
|
||||
},
|
||||
ministry: {
|
||||
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
|
||||
about: [
|
||||
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
|
||||
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
|
||||
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
|
||||
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
|
||||
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
|
||||
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
|
||||
},
|
||||
multicampus: {
|
||||
title: 'Multi-campus church — three campuses',
|
||||
about: [
|
||||
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
|
||||
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
|
||||
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
|
||||
'Kids church with kids check-in and volunteer rosters at Central and North.',
|
||||
'All six upcoming and ten past events of the events demo, spread over the campuses.',
|
||||
],
|
||||
campuses: true,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
|
||||
past: null,
|
||||
extras: ['series', 'kids', 'volunteers'],
|
||||
},
|
||||
};
|
||||
|
||||
let profile = PROFILES[PROFILE_NAME];
|
||||
|
||||
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
|
||||
let campuses = [];
|
||||
|
||||
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
|
||||
|
||||
function buildCampuses() {
|
||||
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
|
||||
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
|
||||
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
|
||||
});
|
||||
campuses = [
|
||||
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
|
||||
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
|
||||
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
|
||||
];
|
||||
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
|
||||
}
|
||||
|
||||
/** Spreads the events over the campuses; the big ones stay church-wide. */
|
||||
function assignCampuses() {
|
||||
const byTitle = {
|
||||
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
|
||||
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
|
||||
};
|
||||
let i = 0;
|
||||
for (const event of rows.event) {
|
||||
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
|
||||
if (event.seriesId || event.kidsCheckIn) continue;
|
||||
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
|
||||
event.campusId = idx === null ? null : campuses[idx].id;
|
||||
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
|
||||
}
|
||||
}
|
||||
|
||||
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
|
||||
async function scopeStaff(tx) {
|
||||
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
|
||||
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
|
||||
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event series and a season pass ──────────────────────────────────────────
|
||||
|
||||
function buildBreakfast(people, { start, title, sold }) {
|
||||
const event = addEvent({
|
||||
title,
|
||||
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
|
||||
startDate: start,
|
||||
endDate: addTime(start, 2.5 * HOUR),
|
||||
registrationDeadline: addTime(start, -2 * DAY),
|
||||
price: 150,
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'),
|
||||
location: 'Church hall',
|
||||
contactName: 'Sipho Dlamini',
|
||||
});
|
||||
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
|
||||
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
|
||||
for (const user of shuffle(people.men).slice(0, sold)) {
|
||||
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
|
||||
}
|
||||
return { event, ticket };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
|
||||
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
|
||||
* each breakfast is at a different campus.
|
||||
*/
|
||||
function buildSeries(people) {
|
||||
const series = add('eventSeries', {
|
||||
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
|
||||
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
|
||||
const pastSpec = (title, daysAgo, speaker) => ({
|
||||
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
|
||||
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
|
||||
options: [{ name: 'Breakfast ticket', price: 150 }],
|
||||
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
|
||||
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
|
||||
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
|
||||
showRate: 0.9,
|
||||
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
|
||||
});
|
||||
const past = [
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
|
||||
];
|
||||
const current = buildMensBreakfast(people);
|
||||
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
|
||||
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
|
||||
[...past, current, next.event, later.event].forEach((event, i) => {
|
||||
event.seriesId = series.id;
|
||||
// Followers were told when each came out (so the series job doesn't email demo people).
|
||||
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
|
||||
const campus = campusAt(i);
|
||||
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
|
||||
});
|
||||
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
|
||||
|
||||
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
|
||||
const pass = add('seasonPass', {
|
||||
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
|
||||
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
|
||||
});
|
||||
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
|
||||
for (const user of shuffle(people.men).slice(0, 6)) {
|
||||
const at = pastDate(15, 1);
|
||||
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
|
||||
[next, later].forEach((b, i) => {
|
||||
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
|
||||
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
|
||||
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
|
||||
add('payment', {
|
||||
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
|
||||
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
|
||||
});
|
||||
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
|
||||
});
|
||||
}
|
||||
return [current, next.event, later.event];
|
||||
}
|
||||
|
||||
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
|
||||
|
||||
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
|
||||
return Array.from({ length: count }, (_, k) => {
|
||||
const startsAt = addTime(first, k * everyDays * DAY);
|
||||
return add('eventSession', {
|
||||
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
|
||||
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check-ins per session that has ended, for the event's tickets. */
|
||||
function attendSessions(event, sessions, showRate) {
|
||||
const attended = new Map();
|
||||
for (const s of sessions.filter(x => x.endsAt < NOW)) {
|
||||
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
|
||||
if (!chance(showRate)) continue;
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
|
||||
attended.set(t.id, (attended.get(t.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return attended;
|
||||
}
|
||||
|
||||
const CERTIFICATE = (min) => ({
|
||||
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
|
||||
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
|
||||
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
|
||||
});
|
||||
|
||||
function buildCourses(people) {
|
||||
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
|
||||
const first = lastWeekday(15, 3, 18, 30);
|
||||
const alpha = addEvent({
|
||||
title: 'Alpha Course',
|
||||
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
|
||||
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
|
||||
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
|
||||
});
|
||||
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
|
||||
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
|
||||
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 30)) {
|
||||
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
|
||||
}
|
||||
attendSessions(alpha, alphaSessions, 0.85);
|
||||
|
||||
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
|
||||
const mFirst = lastWeekday(80, 4, 19);
|
||||
const marriage = addEvent({
|
||||
title: 'The Marriage Course',
|
||||
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
|
||||
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
|
||||
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
|
||||
});
|
||||
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
|
||||
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 14)) {
|
||||
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
|
||||
}
|
||||
const attended = attendSessions(marriage, mSessions, 0.88);
|
||||
const issuedAt = addTime(mSessions[5].endsAt, DAY);
|
||||
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
|
||||
const n = attended.get(t.id) || 0;
|
||||
if (n < 5) continue;
|
||||
const owner = rows.user.find(u => u.id === t.userId);
|
||||
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
|
||||
}
|
||||
return [alpha];
|
||||
}
|
||||
|
||||
function buildLeadershipConference(people) {
|
||||
const day1 = nextWeekday(40, 4, 8, 30);
|
||||
const event = addEvent({
|
||||
title: 'Lead Well Leadership Conference',
|
||||
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
|
||||
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
|
||||
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
|
||||
});
|
||||
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
|
||||
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
|
||||
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
|
||||
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
|
||||
const buyers = shuffle(people.everyone);
|
||||
for (const user of buyers.splice(0, 34)) {
|
||||
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
|
||||
}
|
||||
for (const user of buyers.splice(0, 26)) {
|
||||
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
|
||||
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
|
||||
}
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Kids church with kids check-in ──────────────────────────────────────────
|
||||
|
||||
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
|
||||
const inFamily = new Set();
|
||||
const kidsChurches = [];
|
||||
|
||||
function ageAt(dob, at) {
|
||||
let age = at.getFullYear() - dob.getFullYear();
|
||||
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
|
||||
return Math.max(0, age);
|
||||
}
|
||||
|
||||
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
|
||||
function kidsCode(taken) {
|
||||
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
|
||||
for (;;) {
|
||||
let c = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
|
||||
if (!taken.has(c)) { taken.add(c); return c; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
|
||||
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
|
||||
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
|
||||
*/
|
||||
function buildKidsChurch(people, campus = null) {
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const first = lastWeekday(28, 0, 9, 0);
|
||||
const event = addEvent({
|
||||
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
|
||||
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
|
||||
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
|
||||
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
|
||||
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
|
||||
});
|
||||
const sessions = addSessions(event, first, 12, { minutes: 90 });
|
||||
// A session today, so the desk and room screens have children in right now whatever day the
|
||||
// demo is shown (on a Sunday it's that Sunday's session).
|
||||
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
|
||||
if (!live) {
|
||||
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
|
||||
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
|
||||
sessions.push(live);
|
||||
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
|
||||
}
|
||||
const option = addOption(event, { name: 'Kids check-in', price: 0 });
|
||||
event.kidsOptionId = option.id;
|
||||
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
|
||||
event.kidsRoomSetId = set.id;
|
||||
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
|
||||
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
|
||||
|
||||
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
|
||||
const families = parents.map(parent => {
|
||||
inFamily.add(parent.id);
|
||||
const { last } = userMeta.get(parent.id);
|
||||
const createdAt = pastDate(200, 40);
|
||||
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
|
||||
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
|
||||
if (chance(0.5)) {
|
||||
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
|
||||
}
|
||||
const children = Array.from({ length: int(1, 3) }, () => {
|
||||
const girl = chance(0.5);
|
||||
const dob = new Date(NOW);
|
||||
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
|
||||
dob.setHours(0, 0, 0, 0);
|
||||
return add('child', {
|
||||
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
|
||||
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
|
||||
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
|
||||
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
|
||||
lastCheckInAt: null, createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
return { parent, family, children };
|
||||
});
|
||||
|
||||
const held = sessions.filter(s => s.endsAt < NOW);
|
||||
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
|
||||
let liveFamilies = 0;
|
||||
for (const f of families) {
|
||||
const attending = held.filter(() => chance(0.75));
|
||||
// About half the families are in right now (checked in, not yet collected).
|
||||
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
|
||||
if (inNow) { attending.push(live); liveFamilies++; }
|
||||
if (!attending.length) continue;
|
||||
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
|
||||
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
|
||||
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
|
||||
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
|
||||
const attendees = regLines[0].attendees;
|
||||
for (const s of attending) {
|
||||
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
|
||||
const isLive = s === live;
|
||||
f.children.forEach((c, i) => {
|
||||
if (!chance(0.92)) return;
|
||||
const at = isLive
|
||||
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
|
||||
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
|
||||
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
|
||||
const desk = pick(staffTeam);
|
||||
add('kidsCheckIn', {
|
||||
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
|
||||
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
|
||||
checkedInAt: at, checkedInById: desk.id,
|
||||
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
|
||||
checkedOutById: isLive ? null : pick(staffTeam).id,
|
||||
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
|
||||
});
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
|
||||
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const s of [...held, live]) {
|
||||
const isLive = s === live;
|
||||
if (isLive && live.startsAt > NOW) continue;
|
||||
for (const room of rooms) {
|
||||
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
|
||||
add('kidsRoomDuty', {
|
||||
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
|
||||
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
|
||||
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
kidsChurches.push({ event, sessions, rooms });
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
|
||||
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
|
||||
* marked as sent, so the reminder job doesn't email demo people.
|
||||
*/
|
||||
function addRoster(people, event, { session = null, roles, rooms = [] }) {
|
||||
const start = session ? session.startsAt : event.startDate;
|
||||
const held = (session ? session.endsAt : event.endDate) < NOW;
|
||||
const daysAway = (start - NOW) / DAY;
|
||||
const pool = shuffle(people.everyone);
|
||||
const slots = [];
|
||||
roles.forEach(([name, needed, roomName], order) => {
|
||||
const role = add('servingRole', {
|
||||
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
|
||||
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
|
||||
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
|
||||
for (const user of pool.splice(0, filled)) {
|
||||
const checked = held && chance(0.9);
|
||||
// Most scan the QR code themselves; now and then a staff member ticks them in.
|
||||
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
|
||||
slots.push(add('volunteerSlot', {
|
||||
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
|
||||
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
|
||||
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
|
||||
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
|
||||
}));
|
||||
}
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildVolunteers(people, built) {
|
||||
for (const { event, sessions, rooms } of kidsChurches) {
|
||||
event.volunteerCheckInToken = hex();
|
||||
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
|
||||
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
|
||||
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
|
||||
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
|
||||
coming.forEach((session, i) => {
|
||||
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
|
||||
// Someone can't make the next Sunday and has asked for cover.
|
||||
if (i === 0 && slots.length) {
|
||||
const slot = slots[0];
|
||||
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
|
||||
}
|
||||
});
|
||||
}
|
||||
const dayEvent = built.find(e => e.title === 'Family Fun Day');
|
||||
if (dayEvent) {
|
||||
dayEvent.volunteerCheckInToken = hex();
|
||||
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
|
||||
}
|
||||
}
|
||||
|
||||
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
|
||||
function buildExtras(people, built) {
|
||||
const out = [];
|
||||
const has = (x) => profile.extras.includes(x);
|
||||
if (has('series')) out.push(...buildSeries(people));
|
||||
if (has('courses')) out.push(...buildCourses(people));
|
||||
if (has('conference')) out.push(...buildLeadershipConference(people));
|
||||
if (has('kids')) {
|
||||
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
|
||||
else out.push(...buildKidsChurch(people));
|
||||
}
|
||||
if (has('volunteers')) buildVolunteers(people, built.concat(out));
|
||||
return out;
|
||||
}
|
||||
|
||||
function listProfiles() {
|
||||
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
|
||||
for (const [name, p] of Object.entries(PROFILES)) {
|
||||
console.log(` ${name.padEnd(12)} ${p.title}`);
|
||||
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Funnel analytics ────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
|
||||
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
|
||||
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
|
||||
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
|
||||
|
||||
for (const event of rows.event) {
|
||||
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
|
||||
for (const event of rows.event.filter(e => !e.isHidden)) {
|
||||
const raw = [];
|
||||
const session = (stages, at) => {
|
||||
const sessionId = uuidv4();
|
||||
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
|
||||
|
||||
async function clearData(tx) {
|
||||
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
|
||||
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
|
||||
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
|
||||
// after the admin's links to them are cleared.
|
||||
const userRefs = await tx.$queryRaw`
|
||||
SELECT DISTINCT ref.relname AS table, att.attname AS column
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_class ref ON ref.oid = c.confrelid
|
||||
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
|
||||
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
|
||||
const deleteInstead = new Set(userRefs.map(r => r.table));
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
|
||||
if (toClear.length) {
|
||||
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
|
||||
}
|
||||
if (userRefs.length) {
|
||||
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
|
||||
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
|
||||
}
|
||||
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
|
||||
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
|
||||
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
|
||||
@@ -1269,6 +1827,7 @@ function describeDatabase() {
|
||||
}
|
||||
|
||||
async function confirm(admin) {
|
||||
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
|
||||
if (SKIP_PROMPT) return true;
|
||||
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
|
||||
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
|
||||
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (LIST_ONLY) { listProfiles(); return; }
|
||||
if (!profile) {
|
||||
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
|
||||
listProfiles();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
|
||||
if (!admin) {
|
||||
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
|
||||
@@ -1295,15 +1861,11 @@ async function main() {
|
||||
|
||||
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
const people = buildPeople();
|
||||
const events = [
|
||||
buildMensBreakfast(people),
|
||||
buildFamilyFunDay(people),
|
||||
buildWomensConference(people),
|
||||
buildYouthCamp(people),
|
||||
buildCarols(people),
|
||||
buildMarriageWeekend(people),
|
||||
];
|
||||
if (profile.campuses) buildCampuses();
|
||||
const events = profile.upcoming.map(key => UPCOMING[key](people));
|
||||
events.push(...buildExtras(people, events));
|
||||
buildPastEvents(people);
|
||||
if (profile.campuses) assignCampuses();
|
||||
buildAnalytics();
|
||||
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
@@ -1311,6 +1873,13 @@ async function main() {
|
||||
for (const model of INSERT_ORDER) {
|
||||
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
|
||||
}
|
||||
if (profile.campuses) await scopeStaff(tx);
|
||||
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
|
||||
await tx.appSetting.upsert({
|
||||
where: { key: 'campuses_enabled' },
|
||||
update: { value: profile.campuses ? 'true' : 'false' },
|
||||
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
|
||||
});
|
||||
return result;
|
||||
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
|
||||
clearScheduledEmails();
|
||||
@@ -1348,7 +1917,17 @@ async function main() {
|
||||
].filter(Boolean).join(', ');
|
||||
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
|
||||
}
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
const extras = [
|
||||
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
|
||||
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
|
||||
count('eventSession') && `${count('eventSession')} sessions`,
|
||||
count('certificate') && `${count('certificate')} certificates`,
|
||||
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
|
||||
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
|
||||
].filter(Boolean);
|
||||
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
|
||||
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
|
||||
console.log(`\nKept: site settings and ${admin.email}.`);
|
||||
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
|
||||
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,13 @@ const cleanCapacity = (v) => {
|
||||
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
|
||||
return n;
|
||||
};
|
||||
// Kids rooms: ages in whole years and children per leader. Blank = no limit.
|
||||
const cleanSmallInt = (v, what, min, max) => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < min || n > max) throw allocationError(`${what} must be a whole number from ${min} to ${max}.`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Named attendees on the event's live registrations. */
|
||||
async function eventAttendees(eventId) {
|
||||
@@ -110,8 +117,14 @@ const deleteSet = async (req, res) => {
|
||||
};
|
||||
|
||||
async function groupData(body, setId, existing = null) {
|
||||
const { name, capacity, gender, leaderUserId } = body || {};
|
||||
const { name, capacity, gender, leaderUserId, minAge, maxAge, ratio } = body || {};
|
||||
const data = {};
|
||||
if (minAge !== undefined) data.minAge = cleanSmallInt(minAge, 'The youngest age', 0, 25);
|
||||
if (maxAge !== undefined) data.maxAge = cleanSmallInt(maxAge, 'The oldest age', 0, 25);
|
||||
if (ratio !== undefined) data.ratio = cleanSmallInt(ratio, 'Children per leader', 1, 100);
|
||||
const lo = data.minAge !== undefined ? data.minAge : existing?.minAge;
|
||||
const hi = data.maxAge !== undefined ? data.maxAge : existing?.maxAge;
|
||||
if (lo != null && hi != null && lo > hi) throw allocationError("The youngest age can't be more than the oldest age.");
|
||||
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
|
||||
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
|
||||
if (gender !== undefined) data.gender = cleanGender(gender);
|
||||
@@ -132,7 +145,7 @@ async function groupData(body, setId, existing = null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId?, minAge?, maxAge?, ratio? }
|
||||
const createGroup = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
|
||||
@@ -21,9 +21,13 @@ const trackStage = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const getFunnel = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
|
||||
const asked = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (asked.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
// Campus-scoped supervisors only get the events they can see.
|
||||
const scopeWhere = await require('../utils/campusScope').listWhere(req);
|
||||
const events = await prisma.event.findMany({ where: { id: { in: asked }, ...scopeWhere }, select: { id: true, title: true } });
|
||||
const eventIds = events.map(e => e.id);
|
||||
if (eventIds.length === 0) { res.status(404); throw new Error('Event not found'); }
|
||||
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
|
||||
// Per event too, so events can be compared.
|
||||
const perEvent = [];
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
|
||||
|
||||
const audit = (req, action, campus, metadata) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// A slug that's already taken → a message instead of a Prisma error.
|
||||
function friendlyError(e) {
|
||||
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
|
||||
return e.message || safeErrorMessage(e);
|
||||
}
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
};
|
||||
|
||||
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
|
||||
// @route GET /api/campuses
|
||||
// @access Public
|
||||
const getCampuses = async (req, res) => {
|
||||
try {
|
||||
const enabled = await campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc A campus landing page: the campus (active only)
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
// @access Public
|
||||
const getCampusBySlug = async (req, res) => {
|
||||
try {
|
||||
const campus = (await campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
|
||||
// @route GET /api/campuses/admin
|
||||
// @access Private/Admin
|
||||
const getCampusesAdmin = async (req, res) => {
|
||||
try {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
|
||||
// @route PUT /api/campuses/settings body: { enabled }
|
||||
// @access Private/Admin
|
||||
const updateCampusSettings = async (req, res) => {
|
||||
try {
|
||||
const enabled = req.body?.enabled === true;
|
||||
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
|
||||
res.json({ enabled });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Add a campus (added at the end of the list)
|
||||
// @route POST /api/campuses
|
||||
// @access Private/Admin
|
||||
const createCampus = async (req, res) => {
|
||||
try {
|
||||
const { data, error } = cleanCampusInput(req.body);
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(req, 'campus_created', campus, { slug: campus.slug });
|
||||
res.status(201).json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change a campus: details, summary recipients, archived or not
|
||||
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
|
||||
// @access Private/Admin
|
||||
const updateCampus = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return res.status(404).json({ message: 'Campus not found' });
|
||||
const { data, error } = cleanCampusInput(req.body, { partial: true });
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
|
||||
if (Array.isArray(req.body?.notifyRecipientIds)) {
|
||||
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange = null;
|
||||
if (req.body?.archived !== undefined) {
|
||||
const archived = req.body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(req, 'campus_updated', campus, { changed });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put the campuses in this order (filters and lists follow it)
|
||||
// @route PUT /api/campuses/order body: { ids: [...] }
|
||||
// @access Private/Admin
|
||||
const reorderCampuses = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
|
||||
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
|
||||
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
|
||||
// @route DELETE /api/campuses/:id
|
||||
// @access Private/Admin
|
||||
const deleteCampus = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(req, 'campus_deleted', campus, { slug: campus.slug });
|
||||
res.json({ message: 'Campus deleted' });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
// ?registrants=true) people registered for X's events in the last 12 months. The
|
||||
// composer sends to these ids as usual, so news consent is still checked on sending.
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
// @access Private/Supervisor
|
||||
const getCampusAudience = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ campus, users });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
|
||||
@@ -291,9 +291,12 @@ const resolvePending = async (req, res) => {
|
||||
const getCashupAudit = async (req, res) => {
|
||||
try {
|
||||
const { eventId, from, to } = req.query;
|
||||
// Campus-scoped supervisors only see cashups they could do: their campuses' events.
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
|
||||
const where = {};
|
||||
if (eventId) where.eventId = eventId;
|
||||
if (scope) where.event = require('../utils/campusScope').manageWhere(scope);
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
|
||||
@@ -5,18 +5,29 @@ const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundle } = require('../utils/bundles');
|
||||
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
|
||||
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope, eventIds) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
@@ -119,7 +130,9 @@ function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||||
const scope = await scopeFor(req);
|
||||
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
|
||||
.filter(c => couponAllowed(scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
@@ -157,7 +170,7 @@ const getCoupon = async (req, res) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||||
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -173,6 +186,7 @@ const createCoupon = async (req, res) => {
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
@@ -198,12 +212,14 @@ const createCoupon = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
const scope = await scopeFor(req);
|
||||
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
@@ -327,9 +343,10 @@ const listRedemptions = async (req, res) => {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
@@ -346,7 +363,7 @@ const listRedemptions = async (req, res) => {
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
|
||||
@@ -8,6 +8,24 @@ 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) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && !canManage(scope, campusId)) {
|
||||
const err = new Error(campusId == null
|
||||
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
|
||||
: 'You can only put events at your own campuses.');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
|
||||
|
||||
@@ -87,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;
|
||||
@@ -125,6 +157,12 @@ const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
|
||||
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(),
|
||||
title,
|
||||
@@ -160,6 +198,8 @@ const createEvent = async (req, res) => {
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
|
||||
...(seriesId ? { seriesId } : {}),
|
||||
...scheduleData(req.body),
|
||||
};
|
||||
if (data.scheduleType === 'sessions') {
|
||||
@@ -175,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),
|
||||
});
|
||||
|
||||
@@ -325,15 +365,21 @@ const getEvents = async (req, res) => {
|
||||
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
|
||||
// ?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: {
|
||||
isActive: true,
|
||||
isHidden: false,
|
||||
goLiveAt: { lte: new Date() },
|
||||
endDate: { gte: new Date() }
|
||||
endDate: { gte: new Date() },
|
||||
...campusFilter,
|
||||
},
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
|
||||
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)
|
||||
@@ -350,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.
|
||||
@@ -383,6 +430,7 @@ const getAllEvents = async (req, res) => {
|
||||
include: {
|
||||
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,6 +461,10 @@ const getEventsAll = async (req, res) => {
|
||||
if (!includeInactive) where.isActive = true;
|
||||
if (!includePast) where.endDate = { gte: new Date() };
|
||||
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
|
||||
const enabled = await campusesEnabled();
|
||||
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
|
||||
const scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -420,11 +472,13 @@ const getEventsAll = async (req, res) => {
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
where: scoped,
|
||||
include: {
|
||||
eventOptions: optionInclude ? { include: optionInclude } : true,
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
@@ -452,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 };
|
||||
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.
|
||||
@@ -496,9 +550,14 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
|
||||
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
||||
// who need to load inactive events for cashup, editing, etc.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
|
||||
// public view of other campuses' events (and not their notification recipients).
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
@@ -602,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) {
|
||||
@@ -627,7 +687,7 @@ const getEventIcs = async (req, res) => {
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
@@ -688,6 +748,16 @@ const updateEvent = async (req, res) => {
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
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 }); }
|
||||
}
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
description: description !== undefined ? description : event.description,
|
||||
@@ -725,6 +795,8 @@ const updateEvent = async (req, res) => {
|
||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||
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),
|
||||
@@ -749,6 +821,12 @@ const updateEvent = async (req, res) => {
|
||||
: null;
|
||||
});
|
||||
sessionsUtil.forgetSchedule(updatedEvent.id);
|
||||
if (campusChanged) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed',
|
||||
targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
|
||||
@@ -2105,6 +2183,7 @@ const getEventByAlias = async (req, res) => {
|
||||
|
||||
module.exports = {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
|
||||
@@ -151,7 +151,7 @@ const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
where: { eventId: { in: ids }, ...(await require('../utils/campusScope').listWhere(req, 'event')) },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
|
||||
@@ -16,6 +16,7 @@ const listFormResponses = async (req, res) => {
|
||||
const registrationFilter = {};
|
||||
if (eventId) registrationFilter.eventId = String(eventId);
|
||||
if (userId) registrationFilter.userId = String(userId);
|
||||
Object.assign(registrationFilter, await require('../utils/campusScope').listWhere(req, 'event'));
|
||||
if (Object.keys(registrationFilter).length > 0) {
|
||||
// Prisma relation filter requires `is` wrapper
|
||||
where.registration = { is: registrationFilter };
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { licenceStatus } = require('../hub/licence');
|
||||
const hub = require('../hub/client');
|
||||
const { APP_VERSION } = require('../hub/report');
|
||||
|
||||
function compareVersions(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] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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,
|
||||
daysLeft: s.daysLeft,
|
||||
endsAt: s.endsAt,
|
||||
graceEndsAt: s.graceEndsAt,
|
||||
licence: s.claims ? {
|
||||
customer: s.claims.customer,
|
||||
plan: s.claims.plan,
|
||||
status: s.claims.status,
|
||||
licenceExpiresAt: s.claims.licenceExpiresAt,
|
||||
graceDays: s.claims.graceDays,
|
||||
issuedAt: s.claims.issuedAt,
|
||||
validUntil: s.claims.validUntil,
|
||||
limits: s.claims.limits || {},
|
||||
} : null,
|
||||
connected: s.connected,
|
||||
keyConfigured: s.keyConfigured,
|
||||
hubUrl: hub.hubUrl(),
|
||||
lastHeartbeatAt: s.lastHeartbeatAt,
|
||||
lastError: s.lastError,
|
||||
version: APP_VERSION,
|
||||
latestVersion: s.latestVersion,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Licence and Hub status (for the admin banner and Site settings → Licence)
|
||||
// @route GET /api/hub/status
|
||||
// @access Private/Admin
|
||||
const getStatus = async (req, res) => {
|
||||
try {
|
||||
res.json(await statusPayload());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Connect this install to the Hub with a licence key (also the /setup licence step)
|
||||
// @route POST /api/hub/connect body: { licenceKey }
|
||||
// @access Private/Admin
|
||||
const connect = async (req, res) => {
|
||||
try {
|
||||
const licenceKey = String(req.body?.licenceKey || '').trim();
|
||||
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
|
||||
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
|
||||
const claims = await hub.connect(licenceKey, me);
|
||||
hub.heartbeatSoon();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
const status = e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
res.status(status >= 500 && e.code ? 502 : status).json({ message: e.message || safeErrorMessage(e), code: e.code });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a heartbeat now (checks the connection and picks up licence changes)
|
||||
// @route POST /api/hub/heartbeat
|
||||
// @access Private/Admin
|
||||
const heartbeatNow = async (req, res) => {
|
||||
try {
|
||||
await hub.heartbeat();
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
res.status(502).json({ message: e.message || safeErrorMessage(e), code: e.code, status: await statusPayload({ fresh: true }).catch(() => null) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask the Hub for this site to be updated (the provider is told; nothing runs here)
|
||||
// @route POST /api/hub/update-request
|
||||
// @access Private/Admin
|
||||
const requestUpdate = async (req, res) => {
|
||||
try {
|
||||
const result = await hub.requestUpdate();
|
||||
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) {
|
||||
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 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,448 @@
|
||||
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 { findOrCreateAccount } = require('../utils/accountMatch');
|
||||
const { listWhere, withScope } = require('../utils/campusScope');
|
||||
const kids = require('../utils/kids');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
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) });
|
||||
|
||||
/** Child fields from a request; only supervisors, admins and the child's parents set custody notes. */
|
||||
function childInput(req, { parent = false } = {}) {
|
||||
const body = { ...(req.body || {}) };
|
||||
if (!parent && !SUPERVISORS.includes(req.user.role)) delete body.custodyNotes;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function saveChild(req, familyId, childId, { parent }) {
|
||||
const existing = childId ? await prisma.child.findFirst({ where: { id: String(childId), familyId } }) : null;
|
||||
if (childId && !existing) throw kids.kidsError('Child not found', 404);
|
||||
const data = kids.cleanChild(childInput(req, { parent }), existing);
|
||||
const child = existing
|
||||
? await prisma.child.update({ where: { id: existing.id }, data })
|
||||
: await prisma.child.create({ data: { ...data, familyId } });
|
||||
if (kids.custodyChanged(existing, data)) audit(req, 'kids_custody_notes_changed', 'child', child.id, { familyId, by: parent ? 'parent' : 'staff' });
|
||||
return child;
|
||||
}
|
||||
|
||||
// ─── My account → Kids (parents) ──────────────────────────────────────────────
|
||||
|
||||
// @route GET /api/kids/me
|
||||
const getMine = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.familyOfUser(req.user.id);
|
||||
if (!family) return res.json({ family: null, qrToken: null, codes: [], pages: [] });
|
||||
const active = await kids.activeForFamily(family.id);
|
||||
res.json({ family: kids.familyView(family), qrToken: family.qrToken, ...active });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me { name }
|
||||
const createMine = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.createFamily({ name: req.body?.name || `${req.user.name || 'My'} family`, userId: req.user.id });
|
||||
res.status(201).json({ family: kids.familyView(family), qrToken: family.qrToken, codes: [], pages: [] });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function myFamilyId(req) {
|
||||
const m = await prisma.familyMember.findUnique({ where: { userId: req.user.id } });
|
||||
if (!m) throw kids.kidsError('Add your family first.', 404);
|
||||
return m.familyId;
|
||||
}
|
||||
|
||||
// @route PUT /api/kids/me { name }
|
||||
const renameMine = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kids.kidsError('Enter a name for the family.');
|
||||
await prisma.family.update({ where: { id }, data: { name } });
|
||||
res.json(kids.familyView(await kids.loadFamily(id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me/children, PUT /api/kids/me/children/:id
|
||||
const saveMyChild = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const child = await saveChild(req, id, req.params.id, { parent: true });
|
||||
res.status(req.params.id ? 200 : 201).json(kids.childView(child));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me/children/:id
|
||||
const deleteMyChild = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const out = await prisma.child.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
||||
if (!out.count) throw kids.kidsError('Child not found', 404);
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/me/adults, PUT /api/kids/me/adults/:id
|
||||
const saveMyAdult = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const data = kids.cleanAdult(req.body);
|
||||
if (req.params.id) {
|
||||
const out = await prisma.authorisedAdult.updateMany({ where: { id: String(req.params.id), familyId: id }, data });
|
||||
if (!out.count) throw kids.kidsError('Not found', 404);
|
||||
return res.json(await prisma.authorisedAdult.findUnique({ where: { id: String(req.params.id) } }));
|
||||
}
|
||||
if (await prisma.authorisedAdult.count({ where: { familyId: id } }) >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
||||
res.status(201).json(await prisma.authorisedAdult.create({ data: { ...data, familyId: id } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me/adults/:id
|
||||
const deleteMyAdult = async (req, res) => {
|
||||
try {
|
||||
const id = await myFamilyId(req);
|
||||
const out = await prisma.authorisedAdult.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
||||
if (!out.count) throw kids.kidsError('Not found', 404);
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/kids/me (leave the family; the last parent leaving deletes it)
|
||||
const leaveMine = async (req, res) => {
|
||||
try {
|
||||
await myFamilyId(req);
|
||||
const out = await prisma.$transaction(tx => kids.removeFromFamily(tx, req.user.id));
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff: the desk and the rooms ────────────────────────────────────────────
|
||||
|
||||
// @desc Events with kids check-in that are on now or soon (and the last week's)
|
||||
// @route GET /api/kids/events
|
||||
const listEvents = async (req, res) => {
|
||||
try {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope({ kidsCheckIn: true, endDate: { gte: since } }, await listWhere(req)),
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true,
|
||||
sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/board?sessionId=&log=1
|
||||
const getBoard = async (req, res) => {
|
||||
try {
|
||||
const data = await kids.board({ eventId: req.params.eventId, sessionId: req.query.sessionId || null });
|
||||
// Polling refreshes don't log; opening the screen does (it shows medical and custody notes).
|
||||
if (req.query.log === '1') {
|
||||
logDataAccess(req, { action: 'kids_rooms', eventId: data.event.id, records: data.checkIns.length, sensitive: true });
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families?q=
|
||||
const searchFamilies = async (req, res) => {
|
||||
try {
|
||||
const rows = await kids.searchFamilies(req.query.q);
|
||||
res.json(rows.map(f => kids.familyView(f)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families/qr/:token
|
||||
const familyByQr = async (req, res) => {
|
||||
try {
|
||||
const raw = String(req.params.token || '').trim().replace(/^FAMILY-/i, '');
|
||||
const f = await prisma.family.findUnique({ where: { qrToken: raw } });
|
||||
if (!f) throw kids.kidsError('That isn\'t a family QR code from this site.', 404);
|
||||
res.json(kids.familyView(await kids.loadFamily(f.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/families/:familyId
|
||||
const getFamily = async (req, res) => {
|
||||
try {
|
||||
res.json(kids.familyView(await kids.loadFamily(req.params.familyId)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
/** A parent from the desk: their account (matched by email/phone, or a new inactive one). */
|
||||
async function parentAccount(input) {
|
||||
const name = String(input?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kids.kidsError('Enter the parent\'s name.');
|
||||
const email = String(input?.email || '').trim();
|
||||
const phone = String(input?.phone || '').trim();
|
||||
if (!email && !phone) throw kids.kidsError('Enter the parent\'s phone number or email address, so the pickup code can be sent to them.');
|
||||
const { user } = await findOrCreateAccount({ name, email, phone });
|
||||
return user;
|
||||
}
|
||||
|
||||
// @desc New family at the desk: a parent (found or created) and their children
|
||||
// @route POST /api/kids/events/:eventId/families { name, parent: { name, phone, email }, children: [...] }
|
||||
const createFamilyAtDesk = async (req, res) => {
|
||||
try {
|
||||
const user = await parentAccount(req.body?.parent);
|
||||
const existing = await kids.familyOfUser(user.id);
|
||||
// Don't tell the desk anything about an account beyond what they typed: if the person is
|
||||
// already in a family, that family is used (they're standing at the desk).
|
||||
const family = existing || await kids.createFamily({ name: req.body?.name || `${user.name.split(' ').slice(-1)[0]} family`, userId: user.id });
|
||||
for (const c of (Array.isArray(req.body?.children) ? req.body.children : []).slice(0, 12)) {
|
||||
await saveChild({ ...req, body: c }, family.id, null, { parent: false });
|
||||
}
|
||||
res.status(existing ? 200 : 201).json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/parents { name, phone, email }
|
||||
const addParentAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
const user = await parentAccount(req.body);
|
||||
const m = await prisma.familyMember.findUnique({ where: { userId: user.id } });
|
||||
if (m && m.familyId !== family.id) throw kids.kidsError('This person is already a parent in another family.', 409);
|
||||
if (!m) await prisma.familyMember.create({ data: { familyId: family.id, userId: user.id } });
|
||||
res.json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/children, PUT .../children/:id
|
||||
const saveChildAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
const child = await saveChild(req, family.id, req.params.childId, { parent: false });
|
||||
res.status(req.params.childId ? 200 : 201).json(kids.childView(child));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/families/:familyId/adults { name, phone, relationship }
|
||||
const addAdultAtDesk = async (req, res) => {
|
||||
try {
|
||||
const family = await kids.loadFamily(req.params.familyId);
|
||||
if (family.adults.length >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
||||
await prisma.authorisedAdult.create({ data: { ...kids.cleanAdult(req.body), familyId: family.id } });
|
||||
res.status(201).json(kids.familyView(await kids.loadFamily(family.id)));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/check-in { familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }
|
||||
const checkIn = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.checkIn({
|
||||
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, familyId: req.body?.familyId,
|
||||
children: req.body?.children, staffUserId: req.user.id,
|
||||
});
|
||||
const fresh = out.checkIns.filter(c => !out.already.includes(c.id));
|
||||
if (fresh.length) kids.sendCheckInMessages({ event: out.event, family: out.family, code: out.code, checkIns: fresh, adultId: req.body?.sendToAdultId || null }).catch(() => {});
|
||||
res.status(201).json({
|
||||
code: out.code,
|
||||
qr: kids.QR_PREFIX + out.code,
|
||||
occasion: out.occasion,
|
||||
family: { id: out.family.id, name: out.family.name },
|
||||
checkIns: out.checkIns.map(c => {
|
||||
const child = c.child ? kids.childView(c.child) : null;
|
||||
return {
|
||||
id: c.id, name: c.childName, firstName: child?.firstName || c.childName, room: c.room, already: out.already.includes(c.id),
|
||||
sticker: kids.stickerAlert(child?.allergies, child?.medicalNotes),
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/check-out { code, checkInIds?, collectedBy, roomId?, sessionId? }
|
||||
const checkOut = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.checkOut({
|
||||
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, code: req.body?.code, checkInIds: req.body?.checkInIds,
|
||||
collectedBy: req.body?.collectedBy, roomId: req.body?.roomId || null, staffUserId: req.user.id,
|
||||
});
|
||||
res.json({ checkedOut: out.checkIns.map(c => ({ id: c.id, name: c.childName, room: c.room?.name || null })) });
|
||||
} catch (error) {
|
||||
if (error.code === 'WRONG_CODE') audit(req, 'kids_wrong_code', 'event', req.params.eventId, { typed: String(req.body?.code || '').slice(0, 20), roomId: req.body?.roomId || null });
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Look up a code before checking out: which children are on it (to confirm who's collected)
|
||||
// @route GET /api/kids/events/:eventId/code/:code?sessionId=
|
||||
const lookupCode = async (req, res) => {
|
||||
try {
|
||||
const event = await kids.loadKidsEvent(req.params.eventId);
|
||||
const { occasion } = kids.occasionFor(event, event.sessions, req.query.sessionId || null);
|
||||
const code = kids.normaliseCode(req.params.code);
|
||||
const row = code ? await prisma.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
|
||||
if (!row) return res.json({ code, found: false, children: [] });
|
||||
const rows = await prisma.kidsCheckIn.findMany({ where: { pickupCodeId: row.id }, include: { room: { select: { id: true, name: true } } }, orderBy: { childName: 'asc' } });
|
||||
const family = row.familyId ? kids.familyView(await kids.loadFamily(row.familyId), { notes: false }) : null;
|
||||
res.json({
|
||||
code, found: true,
|
||||
family: family ? { name: family.name, parents: family.parents, adults: family.adults } : null,
|
||||
children: rows.map(c => ({ id: c.id, name: c.childName, room: c.room, checkedOutAt: c.checkedOutAt, collectedBy: c.collectedBy })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/check-ins/:id/override { reason, collectedBy }
|
||||
const overrideCheckOut = async (req, res) => {
|
||||
try {
|
||||
const ci = await kids.overrideCheckOut({ checkInId: req.params.id, reason: req.body?.reason, collectedBy: req.body?.collectedBy, staffUserId: req.user.id });
|
||||
audit(req, 'kids_checkout_override', 'kids_check_in', ci.id, { child: ci.childName, collectedBy: ci.collectedBy, reason: ci.overrideReason });
|
||||
res.json({ id: ci.id, checkedOutAt: ci.checkedOutAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/check-ins/:id/page { message? }
|
||||
const pageParent = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.pageParent({ checkInId: req.params.id, message: req.body?.message, staffUserId: req.user.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/kids/check-ins/:id/room { roomId }
|
||||
const moveRoom = async (req, res) => {
|
||||
try {
|
||||
const ci = await kids.moveRoom({ checkInId: req.params.id, roomId: req.body?.roomId });
|
||||
res.json({ id: ci.id, roomId: ci.roomId });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/duty { roomId, on, sessionId? }
|
||||
const setDuty = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.setDuty({ eventId: req.params.eventId, sessionId: req.body?.sessionId || null, roomId: req.body?.roomId, userId: req.user.id, on: req.body?.on !== false }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/alerts/:id/resolve
|
||||
const resolveAlert = async (req, res) => {
|
||||
try {
|
||||
const out = await prisma.kidsAlert.updateMany({ where: { id: String(req.params.id), resolvedAt: null }, data: { resolvedAt: new Date() } });
|
||||
res.json({ resolved: out.count });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route GET /api/kids/events/:eventId/offline?deviceId=&sessionId=
|
||||
const offlineSnapshot = async (req, res) => {
|
||||
try {
|
||||
const data = await kids.offlineSnapshot({ eventId: req.params.eventId, sessionId: req.query.sessionId || null, deviceId: req.query.deviceId });
|
||||
logDataAccess(req, { action: 'kids_offline', eventId: req.params.eventId, records: data.families.length, sensitive: true });
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/kids/events/:eventId/sync { deviceId, occasion, checkIns, checkOuts }
|
||||
const syncOffline = async (req, res) => {
|
||||
try {
|
||||
const out = await kids.syncOffline({ eventId: req.params.eventId, ...(req.body || {}), staffUserId: req.user.id });
|
||||
if (out.checkedIn || out.checkedOut || out.conflicts.length) audit(req, 'kids_offline_sync', 'event', req.params.eventId, { checkedIn: out.checkedIn, checkedOut: out.checkedOut, conflicts: out.conflicts.length });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Supervisor: setup ────────────────────────────────────────────────────────
|
||||
|
||||
// @route GET /api/kids/events/:eventId/settings
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
res.json(await kids.getSettings(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/kids/events/:eventId/settings { enabled?, optionId?, roomSetId? } ('new' makes one)
|
||||
const saveSettings = async (req, res) => {
|
||||
try {
|
||||
const { enabled, optionId, roomSetId } = req.body || {};
|
||||
const out = await kids.saveSettings(req.params.eventId, { enabled, optionId, roomSetId });
|
||||
audit(req, 'kids_settings_updated', 'event', out.event.id, { enabled: out.event.kidsCheckIn, optionId: out.event.kidsOptionId, roomSetId: out.event.kidsRoomSetId });
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Events a supervisor can switch kids check-in on for (upcoming, newest first)
|
||||
// @route GET /api/kids/setup-events
|
||||
const setupEvents = async (req, res) => {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: withScope({ endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, await listWhere(req)),
|
||||
select: { id: true, title: true, startDate: true, kidsCheckIn: true, campusId: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMine, createMine, renameMine, saveMyChild, deleteMyChild, saveMyAdult, deleteMyAdult, leaveMine,
|
||||
listEvents, getBoard, searchFamilies, familyByQr, getFamily, createFamilyAtDesk, addParentAtDesk, saveChildAtDesk, addAdultAtDesk,
|
||||
checkIn, checkOut, lookupCode, overrideCheckOut, pageParent, moveRoom, setDuty, resolveAlert, offlineSnapshot, syncOffline,
|
||||
getSettings, saveSettings, setupEvents,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -351,9 +351,13 @@ const getPayments = async (req, res) => {
|
||||
event: true
|
||||
};
|
||||
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count()
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
|
||||
@@ -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) {
|
||||
@@ -565,8 +568,10 @@ const getRegistrations = async (req, res) => {
|
||||
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
// Campus-scoped staff: registrations for events they can see.
|
||||
const where = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ include });
|
||||
const registrations = await prisma.registration.findMany({ where, include });
|
||||
return res.json(attachComputedTotalsToList(registrations));
|
||||
}
|
||||
|
||||
@@ -575,8 +580,8 @@ const getRegistrations = async (req, res) => {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [registrations, total] = await prisma.$transaction([
|
||||
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count()
|
||||
prisma.registration.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
|
||||
@@ -1505,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).
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
|
||||
const prisma = require('../config/db');
|
||||
const { scopeFor, eventWhere } = require('../utils/campusScope');
|
||||
|
||||
/**
|
||||
* The jobs a campus-scoped supervisor may see and change: messages for events they can see, and
|
||||
* broadcasts they scheduled themselves. Everyone else sees every job.
|
||||
*/
|
||||
async function visibleJobs(req, jobs) {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return jobs;
|
||||
const ids = [...new Set(jobs.map(j => j.eventId).filter(Boolean))];
|
||||
const seen = ids.length
|
||||
? new Set((await prisma.event.findMany({ where: { id: { in: ids }, ...eventWhere(scope) }, select: { id: true } })).map(e => e.id))
|
||||
: new Set();
|
||||
return jobs.filter(j => (j.eventId ? seen.has(j.eventId) : j.createdById === req.user.id));
|
||||
}
|
||||
|
||||
// Normalize job for client UI
|
||||
function toClient(job) {
|
||||
@@ -34,7 +50,7 @@ function toClient(job) {
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.query || {};
|
||||
const raw = listJobs(channel ? { channel: String(channel) } : {});
|
||||
const raw = await visibleJobs(req, listJobs(channel ? { channel: String(channel) } : {}));
|
||||
const filtered = raw
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
@@ -59,7 +75,7 @@ const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text, message } = req.body || {};
|
||||
@@ -92,7 +108,7 @@ const deleteScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
|
||||
const ok = deleteJob(id);
|
||||
if (!ok) return res.status(404).json({ message: 'Job not found' });
|
||||
|
||||
@@ -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 };
|
||||
@@ -10,9 +10,8 @@ const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const where = eventId && eventId !== 'all'
|
||||
? { eventId }
|
||||
: undefined;
|
||||
const { withScope, listWhere } = require('../utils/campusScope');
|
||||
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
|
||||
@@ -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 };
|
||||
@@ -15,6 +15,13 @@ const PAYMENT_PRIVATE_KEYS = new Set([
|
||||
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
|
||||
]);
|
||||
|
||||
// The Hub connection (install id and secret, licence token) is only written by src/hub —
|
||||
// an admin editing it here could borrow another install's licence.
|
||||
const HUB_PRIVATE_KEYS = new Set(require('../hub/client').HUB_KEYS);
|
||||
// Changed through /api/campuses/settings (audited as campuses_toggled), read through GET /api/campuses.
|
||||
const CAMPUS_KEYS = new Set([require('../utils/campuses').SETTING_KEY]);
|
||||
const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS, ...CAMPUS_KEYS]);
|
||||
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
@@ -53,7 +60,7 @@ const getAllSettings = async (req, res) => {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
@@ -89,7 +96,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -128,7 +135,7 @@ const updateSettings = async (req, res) => {
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -205,7 +212,7 @@ const runSetup = async (req, res) => {
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { isDonationLeg } = require('../utils/cashupUtils');
|
||||
const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses');
|
||||
const { scopeFor, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
|
||||
|
||||
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
||||
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
||||
// computed with aggregate queries — never a full payments/events list shipped to the
|
||||
// client just to be reduced down to a couple of numbers.
|
||||
|
||||
async function computeScanStats(userId) {
|
||||
async function computeScanStats(userId, ticketWhere = {}) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const whereBase = { scannedAt: { gte: startOfDay } };
|
||||
const whereBase = { scannedAt: { gte: startOfDay }, ...ticketWhere };
|
||||
|
||||
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }, ...ticketWhere } }),
|
||||
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
@@ -33,8 +35,9 @@ async function computeScanStats(userId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRecentScans(limit = 10) {
|
||||
function getRecentScans(limit = 10, ticketWhere = {}) {
|
||||
return prisma.ticketUsage.findMany({
|
||||
where: ticketWhere,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -53,8 +56,26 @@ function getRecentScans(limit = 10) {
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveEventsCount() {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
||||
function getActiveEventsCount(eventWhere = {}) {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } });
|
||||
}
|
||||
|
||||
/**
|
||||
* `where` pieces for a ?campus= filter (id, slug or 'all') together with the user's campus scope
|
||||
* (1.44.0), or empty for church-wide. A payment belongs to a campus through its event, or its
|
||||
* registration's event — so payments with neither (unlinked donations) aren't counted for a
|
||||
* scoped user. EXCLUDE_DONATION_LEGS uses NOT, so the payment OR never collides with it.
|
||||
*/
|
||||
async function campusFilters(value, scope = null) {
|
||||
const enabled = await campusesEnabled();
|
||||
const eventWhere = withScope(campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }), scopeEventWhere(scope));
|
||||
if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {}, usage: {} };
|
||||
return {
|
||||
eventWhere,
|
||||
byEvent: { event: eventWhere },
|
||||
payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] },
|
||||
usage: { ticket: { event: eventWhere } },
|
||||
};
|
||||
}
|
||||
|
||||
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
|
||||
@@ -64,19 +85,19 @@ const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
async function computePaymentStats({ includeWeekMonth, paymentWhere = {} }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay }, ...paymentWhere } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +119,10 @@ async function computePaymentStats({ includeWeekMonth }) {
|
||||
// @access Private/Staff+
|
||||
const getStaffDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
]);
|
||||
res.json({ scanStats, recentScans });
|
||||
} catch (error) {
|
||||
@@ -113,11 +135,12 @@ const getStaffDashboardStats = async (req, res) => {
|
||||
// @access Private/Supervisor+
|
||||
const getSupervisorDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const cf = await campusFilters('', await scopeFor(req));
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: false }),
|
||||
getActiveEventsCount(),
|
||||
computeScanStats(req.user.id, cf.usage),
|
||||
getRecentScans(10, cf.usage),
|
||||
computePaymentStats({ includeWeekMonth: false, paymentWhere: cf.payment }),
|
||||
getActiveEventsCount(cf.eventWhere),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
@@ -207,21 +230,22 @@ function classifyAndSum(payments) {
|
||||
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
|
||||
// month) vs the month before that, a daily revenue trend for the trailing month,
|
||||
// and the top performing events overall.
|
||||
// @route GET /api/stats/overview
|
||||
// @route GET /api/stats/overview?campus=<id|slug|all> (no campus = church-wide)
|
||||
// @access Private/Supervisor+
|
||||
const getOverviewStats = async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
|
||||
const cf = await campusFilters(req.query.campus, await scopeFor(req));
|
||||
|
||||
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
|
||||
getActiveEventsCount(),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }),
|
||||
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
|
||||
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }),
|
||||
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }),
|
||||
getActiveEventsCount(cf.eventWhere),
|
||||
]);
|
||||
|
||||
const thisTotals = classifyAndSum(thisPayments);
|
||||
@@ -245,7 +269,7 @@ const getOverviewStats = async (req, res) => {
|
||||
// "real revenue" definition as the KPI above: everything except application legs.
|
||||
const revenueByEvent = await prisma.payment.groupBy({
|
||||
by: ['eventId'],
|
||||
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
|
||||
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null }, ...cf.byEvent },
|
||||
_sum: { amount: true },
|
||||
orderBy: { _sum: { amount: 'desc' } },
|
||||
take: 5,
|
||||
@@ -270,7 +294,7 @@ const getOverviewStats = async (req, res) => {
|
||||
// Funnel conversion (event page views → paid) across live events, last 30 days.
|
||||
let funnel = null;
|
||||
try {
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now }, ...cf.eventWhere }, select: { id: true } });
|
||||
if (live.length) {
|
||||
const { funnelFor } = require('../utils/analytics');
|
||||
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
|
||||
@@ -296,6 +320,7 @@ const getOverviewStats = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
campusFilters,
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
|
||||
@@ -50,6 +50,8 @@ const getTickets = async (req, res) => {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
// Campus-scoped supervisors: tickets for events they can see.
|
||||
const scopeW = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
|
||||
const include = {
|
||||
registrationOption: {
|
||||
@@ -66,8 +68,8 @@ const getTickets = async (req, res) => {
|
||||
};
|
||||
|
||||
const [tickets, total] = await prisma.$transaction([
|
||||
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count()
|
||||
prisma.ticket.findMany({ where: scopeW, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count({ where: scopeW })
|
||||
]);
|
||||
|
||||
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
@@ -103,7 +105,7 @@ const getUserTickets = async (req, res) => {
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, campus: { select: { name: true, slug: true } } } },
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
@@ -632,9 +634,11 @@ const getRecentScans = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
||||
const eventId = req.query.eventId || undefined;
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
|
||||
const scans = await prisma.ticketUsage.findMany({
|
||||
where: eventId ? { ticket: { eventId } } : undefined,
|
||||
where: eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : undefined,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
@@ -666,11 +670,14 @@ const getScanStats = async (req, res) => {
|
||||
const eventId = req.query.eventId || undefined;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
// Campus-scoped staff: scans of tickets for events they can see.
|
||||
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
|
||||
const ticketWhere = eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : {};
|
||||
|
||||
// Total scans today (optionally by event)
|
||||
const whereBase = {
|
||||
scannedAt: { gte: startOfDay },
|
||||
...(eventId ? { ticket: { eventId } } : {})
|
||||
...ticketWhere
|
||||
};
|
||||
|
||||
const [totalToday, myToday, lastHour] = await Promise.all([
|
||||
@@ -678,7 +685,7 @@ const getScanStats = async (req, res) => {
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
||||
prisma.ticketUsage.count({
|
||||
where: {
|
||||
...(eventId ? { ticket: { eventId } } : {}),
|
||||
...ticketWhere,
|
||||
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
@@ -929,6 +936,12 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
|
||||
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
|
||||
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
|
||||
|
||||
// Campus (only included while campuses are on)
|
||||
if (ticket.event.campus?.name) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(ticket.event.campus.name, x + pad, cy, { width: innerW });
|
||||
cy += doc.heightOfString(ticket.event.campus.name, { width: innerW }) + 2;
|
||||
}
|
||||
|
||||
// Ticket type
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
|
||||
@@ -983,8 +996,9 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
const showCampus = await require('../utils/campuses').campusesEnabled();
|
||||
const ticketInclude = {
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, ...(showCampus ? { campus: { select: { name: true } } } : {}) } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
|
||||
@@ -537,6 +537,7 @@ const getUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
newsAskedAt: true,
|
||||
noticeVersionAccepted: true
|
||||
}
|
||||
@@ -547,7 +548,9 @@ const getUserProfile = async (req, res) => {
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
// noticePromptPending: a privacy notice version they must accept has been published.
|
||||
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
const campusScope = (await require('../utils/campusScope').scopeFor(req))?.campusIds || null;
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending, campusScope });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -571,7 +574,18 @@ const updateUserProfile = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
|
||||
newHomeCampusId = campus.id;
|
||||
}
|
||||
}
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
@@ -619,6 +633,7 @@ const updateUserProfile = async (req, res) => {
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -633,6 +648,7 @@ const updateUserProfile = async (req, res) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
@@ -769,7 +785,8 @@ const getUserById = async (req, res) => {
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,7 +815,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled, scopedCampusIds } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -834,6 +851,21 @@ const updateUser = async (req, res) => {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js): "Limit to campuses" for supervisors and staff.
|
||||
// Admins and users are never limited, so a role change away from staff clears the limit.
|
||||
const before = await prisma.user.findUnique({ where: { id: user.id }, select: { scopedCampuses: { select: { id: true } } } });
|
||||
const beforeIds = before.scopedCampuses.map(c => c.id).sort();
|
||||
let afterIds = beforeIds;
|
||||
if (!['supervisor', 'staff'].includes(data.role)) afterIds = [];
|
||||
else if (Array.isArray(scopedCampusIds)) {
|
||||
const wanted = [...new Set(scopedCampusIds.filter(x => typeof x === 'string' && x))];
|
||||
const found = await prisma.campus.findMany({ where: { id: { in: wanted } }, select: { id: true } });
|
||||
if (found.length !== wanted.length) { res.status(400); throw new Error('One of those campuses doesn\'t exist.'); }
|
||||
afterIds = wanted.sort();
|
||||
}
|
||||
const scopeChanged = afterIds.join(',') !== beforeIds.join(',');
|
||||
if (scopeChanged) data.scopedCampuses = { set: afterIds.map(id => ({ id })) };
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -847,9 +879,13 @@ const updateUser = async (req, res) => {
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
scopedCampuses: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
if (scopeChanged) {
|
||||
require('../utils/adminAudit').logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'user_campus_scope_changed', targetType: 'User', targetId: updatedUser.id, metadata: { from: beforeIds, to: afterIds }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
@@ -1078,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,
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 (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_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).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const { verifyToken, clearLicenceCache } = require('./licence');
|
||||
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', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
|
||||
|
||||
function hubUrl() {
|
||||
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) await prisma.appSetting.deleteMany({ where: { key } });
|
||||
else await prisma.appSetting.upsert({ where: { key }, update: { value: String(value) }, create: { key, value: String(value) } });
|
||||
}
|
||||
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]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${hubUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
|
||||
async function signedPost(path, body) {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
|
||||
const secret = decrypt(s.hub_install_secret);
|
||||
return post(path, body, raw => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
'X-Hub-Install': s.hub_install_id,
|
||||
'X-Hub-Timestamp': timestamp,
|
||||
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Register this install with a licence key. Returns the licence claims. */
|
||||
async function connect(licenceKey, adminContact) {
|
||||
if (!require('./publicKey').hubPublicKey()) {
|
||||
throw new HubError('This version of Cross Events has no Hub key built in, so it can\'t check licences yet.', 'NO_PUBLIC_KEY', 500);
|
||||
}
|
||||
const install = await report.installInfo();
|
||||
if (!/^https?:\/\//.test(install.url)) {
|
||||
throw new HubError('Set the site\'s web address first (FRONTEND_URL on the server, or Site settings → Organisation).', 'NO_SITE_URL', 400);
|
||||
}
|
||||
const data = await post('/api/installs/register', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
licenceKey: String(licenceKey || '').trim(),
|
||||
install: { ...install, adminContact: adminContact ? { name: adminContact.name || null, email: adminContact.email || null } : null },
|
||||
});
|
||||
const claims = verifyToken(data.licence);
|
||||
if (!claims || claims.installId !== data.installId) {
|
||||
throw new HubError('The Hub answered with a licence this install can\'t verify. Is HUB_URL pointing at the right Hub?', 'BAD_LICENCE', 502);
|
||||
}
|
||||
await save({
|
||||
hub_install_id: data.installId,
|
||||
hub_install_secret: encrypt(data.installSecret),
|
||||
hub_licence: data.licence,
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
...fromHub(data),
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** Send a heartbeat (the `hub-heartbeat` job). Skips quietly when not connected. */
|
||||
async function heartbeat() {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) return { skipped: 'not connected' };
|
||||
const body = { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION, health: await report.health(), stats: await report.stats() };
|
||||
try {
|
||||
const data = await signedPost('/api/installs/heartbeat', body);
|
||||
const claims = verifyToken(data.licence);
|
||||
await save({
|
||||
// Keep the old token if the new one doesn't check out (never accept an unverified one).
|
||||
...(claims && claims.installId === s.hub_install_id ? { hub_licence: data.licence } : {}),
|
||||
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) {
|
||||
await save({ hub_last_error: `${new Date().toISOString()} ${e.code || ''} ${e.message}`.slice(0, 500) });
|
||||
// The Hub doesn't know this install any more (licence released): drop the connection and the
|
||||
// licence, so the site is unlicensed (read-only) and shows "Connect" until a key is entered.
|
||||
if (e.code === 'INSTALL_NOT_FOUND') {
|
||||
await save({ hub_install_id: null, hub_install_secret: null, hub_licence: null });
|
||||
return { ok: false, reconnect: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* 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() {
|
||||
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.
|
||||
*/
|
||||
let soonTimer = null;
|
||||
function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
if (String(process.env.HUB_HEARTBEAT_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
clearTimeout(soonTimer);
|
||||
soonTimer = setTimeout(() => {
|
||||
heartbeat().catch(e => console.warn('[hub] Heartbeat failed:', e?.message || e));
|
||||
}, delayMs);
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Licence checks (docs/hub-protocol.md §4). The Hub signs a licence token with its private
|
||||
* key; this install checks it with the public key and works out its state, offline:
|
||||
*
|
||||
* active — everything works
|
||||
* grace — past the end date for up to graceDays: works, site admins see a banner
|
||||
* suspended — suspended by the Hub, or grace ran out: read-only (no new events,
|
||||
* registrations or payments); tickets still scan, data can be exported
|
||||
* revoked — like suspended, with a permanent banner
|
||||
* unlicensed — not connected to the Hub (never, or the Hub released this install): read-only,
|
||||
* like suspended, until a licence key is connected
|
||||
*
|
||||
* LICENCE_CHECK_DISABLED=true turns read-only off outside production (the end-to-end tests).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { hubPublicKey } = require('./publicKey');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
|
||||
const CACHE_MS = 30 * 1000;
|
||||
|
||||
let publicKeyObject = null;
|
||||
let publicKeyPem = null;
|
||||
function publicKey() {
|
||||
const pem = hubPublicKey();
|
||||
if (!pem) return null;
|
||||
if (pem !== publicKeyPem) {
|
||||
try {
|
||||
publicKeyObject = crypto.createPublicKey(pem);
|
||||
} catch {
|
||||
throw new Error("The Hub public key can't be read — check HUB_PUBLIC_KEY in backend/.env (copy it from the Hub's Settings page).");
|
||||
}
|
||||
publicKeyPem = pem;
|
||||
}
|
||||
return publicKeyObject;
|
||||
}
|
||||
|
||||
/** The token's claims if the Hub signed it, else null. */
|
||||
function verifyToken(token, key = publicKey()) {
|
||||
if (!key) return null;
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3 || parts[0] !== 'hel1') return null;
|
||||
try {
|
||||
const ok = crypto.verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
|
||||
return ok ? JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for verified claims (or null = never connected). Pure, for tests.
|
||||
* Returns { state, readOnly, endsAt, graceEndsAt, daysLeft }.
|
||||
*/
|
||||
function stateFor(claims, installId, now = new Date()) {
|
||||
if (!claims || (installId && claims.installId !== installId)) {
|
||||
return { state: 'unlicensed', readOnly: true, endsAt: null, graceEndsAt: null, daysLeft: null };
|
||||
}
|
||||
const ends = [claims.licenceExpiresAt, claims.validUntil].filter(Boolean).map(d => new Date(d));
|
||||
const endsAt = ends.length ? new Date(Math.min(...ends)) : null;
|
||||
const graceEndsAt = endsAt ? new Date(endsAt.getTime() + (claims.graceDays || 0) * DAY) : null;
|
||||
let state;
|
||||
if (claims.status === 'revoked') state = 'revoked';
|
||||
else if (claims.status === 'suspended') state = 'suspended';
|
||||
else if (!endsAt || now < endsAt) state = 'active';
|
||||
else if (now < graceEndsAt) state = 'grace';
|
||||
else state = 'suspended';
|
||||
const daysLeft = state === 'grace' ? Math.max(0, Math.ceil((graceEndsAt - now) / DAY)) : null;
|
||||
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
|
||||
}
|
||||
|
||||
function checkDisabled() {
|
||||
return process.env.NODE_ENV !== 'production' && String(process.env.LICENCE_CHECK_DISABLED || '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
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', '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 = {
|
||||
keyConfigured: !!publicKey(),
|
||||
connected: !!s.hub_install_id,
|
||||
installId: s.hub_install_id || null,
|
||||
claims: claims && claims.installId === s.hub_install_id ? claims : null,
|
||||
...stateFor(claims, s.hub_install_id || null),
|
||||
...(checkDisabled() ? { readOnly: false } : {}),
|
||||
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
|
||||
lastError: s.hub_last_error || null,
|
||||
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;
|
||||
}
|
||||
|
||||
function clearLicenceCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { verifyToken, stateFor, licenceStatus, clearLicenceCache, READ_ONLY_STATES };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The Cross Events Hub's public licence key (Ed25519). Installs check every licence token with
|
||||
* it, so a licence can't be faked by editing the database. The matching private key exists
|
||||
* only on the Hub server (events-hub repo, `npm run generate-keys`).
|
||||
*
|
||||
* PRODUCTION_PUBLIC_KEY is the key the production Hub prints (also served at /api/public-key); if
|
||||
* the Hub key is regenerated, update it here. HUB_PUBLIC_KEY (env) overrides it — for a local Hub.
|
||||
*/
|
||||
|
||||
const PRODUCTION_PUBLIC_KEY = "MCowBQYDK2VwAyEAcKpEo/JdS96wR3r9yh8uFUxBlzPUd2YKbC60fNRUhoU=";
|
||||
|
||||
/**
|
||||
* A public key as proper PEM, however it was pasted: with real line breaks, with "\n", all on
|
||||
* one line ("-----BEGIN PUBLIC KEY-----MCow…-----END PUBLIC KEY-----"), or just the base64.
|
||||
*/
|
||||
function normalisePem(input) {
|
||||
const text = String(input || '').replace(/\\n/g, '\n').trim();
|
||||
if (!text) return null;
|
||||
const body = text
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return text; // not base64 — let the caller's parse fail clearly
|
||||
const lines = body.match(/.{1,64}/g).join('\n');
|
||||
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----\n`;
|
||||
}
|
||||
|
||||
function hubPublicKey() {
|
||||
return normalisePem(process.env.HUB_PUBLIC_KEY) || normalisePem(PRODUCTION_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
module.exports = { hubPublicKey, normalisePem, PRODUCTION_PUBLIC_KEY };
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Read-only mode while the licence is suspended, revoked or missing (not connected to the Hub):
|
||||
* no new or edited events, and no *new* activity — registrations, tickets added, payments started
|
||||
* or recorded, waitlist places, coupons or bundles.
|
||||
* Everything else keeps working on purpose: scanning tickets for events people already paid for,
|
||||
* viewing and exporting data, refunds and cancellations, privacy requests, logging in, and the
|
||||
* settings needed to renew the licence. Payment webhooks for payments already started also still
|
||||
* arrive (they're mounted before this middleware).
|
||||
*/
|
||||
|
||||
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\/?$/],
|
||||
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/registrations\/?$/],
|
||||
['POST', /^\/api\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
|
||||
['POST', /^\/api\/payments\/?$/],
|
||||
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
|
||||
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
return BLOCKED.some(([m, re]) => m === method && re.test(path));
|
||||
}
|
||||
|
||||
async function licenceReadOnly(req, res, next) {
|
||||
if (!isBlocked(req.method, req.path)) return next();
|
||||
try {
|
||||
const status = await licenceStatus();
|
||||
if (!status.readOnly) return next();
|
||||
return res.status(403).json({
|
||||
code: 'LICENCE_READ_ONLY',
|
||||
message: status.state === 'revoked'
|
||||
? "This site's licence has been revoked, so nothing new can be added and events can't be changed. Tickets still scan and all data can still be viewed and exported."
|
||||
: status.state === 'unlicensed'
|
||||
? "This site has no licence, so nothing new can be added and events can't be changed. A site admin can connect a licence key in Site settings → Licence."
|
||||
: "This site's licence is suspended, so nothing new can be added and events can't be changed for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
|
||||
});
|
||||
} catch {
|
||||
// Never let a licence check failure take the site down.
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { licenceReadOnly, isBlocked, BLOCKED };
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What this install tells the Hub (docs/hub-protocol.md §3): health, and aggregate counts —
|
||||
* no personal information. Income goes as a range, never an amount.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const prisma = require('../config/db');
|
||||
const errorCounter = require('../utils/errorCounter');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const APP_VERSION = require('../../package.json').version;
|
||||
const STARTED_AT = Date.now();
|
||||
|
||||
const INCOME_BANDS = [
|
||||
[1000, 'R0–R1 000'],
|
||||
[5000, 'R1 001–R5 000'],
|
||||
[20000, 'R5 001–R20 000'],
|
||||
[50000, 'R20 001–R50 000'],
|
||||
[100000, 'R50 001–R100 000'],
|
||||
];
|
||||
|
||||
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
|
||||
function incomeBand(amount) {
|
||||
const rand = Math.max(0, Math.round(Number(amount) || 0));
|
||||
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
|
||||
return 'over R100 000';
|
||||
}
|
||||
|
||||
async function postgresVersion() {
|
||||
try {
|
||||
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
|
||||
return String(rows[0]?.server_version || '').split(' ')[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Migrations in the repo that the database hasn't applied. */
|
||||
async function pendingMigrations() {
|
||||
try {
|
||||
const dir = path.join(__dirname, '..', '..', 'prisma', 'migrations');
|
||||
const folders = fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
|
||||
const rows = await prisma.$queryRawUnsafe('SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL');
|
||||
const applied = new Set(rows.map(r => r.migration_name));
|
||||
return folders.filter(f => !applied.has(f)).length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disk() {
|
||||
try {
|
||||
const s = await fs.promises.statfs(path.join(__dirname, '..', '..'));
|
||||
return { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jobsHealth() {
|
||||
try {
|
||||
const { jobStatus } = require('../jobs');
|
||||
const status = await jobStatus(prisma);
|
||||
const since = Date.now() - DAY;
|
||||
const failed = status.jobs.flatMap(j => j.recent).filter(r => r.state === 'failed' && new Date(r.completedOn || r.startedOn || 0).getTime() > since).length;
|
||||
return { mode: status.mode, failedLast24h: failed };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function lastBackupAt() {
|
||||
try {
|
||||
const backups = await require('../utils/backupUtils').listBackups();
|
||||
return backups[0]?.createdAt ? new Date(backups[0].createdAt).toISOString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function health() {
|
||||
let db = true;
|
||||
try { await prisma.$queryRawUnsafe('SELECT 1'); } catch { db = false; }
|
||||
const [jobs, backupAt, diskInfo, pending] = await Promise.all([jobsHealth(), lastBackupAt(), disk(), pendingMigrations()]);
|
||||
return {
|
||||
uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
|
||||
db,
|
||||
jobs,
|
||||
lastBackupAt: backupAt,
|
||||
disk: diskInfo,
|
||||
errorsLast24h: errorCounter.countLast24h(),
|
||||
pendingMigrations: pending,
|
||||
};
|
||||
}
|
||||
|
||||
async function stats(now = new Date()) {
|
||||
const { campusesEnabled } = require('../utils/campuses');
|
||||
const since = new Date(now.getTime() - 30 * DAY);
|
||||
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
|
||||
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
|
||||
prisma.user.count({ where: realUser }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
|
||||
prisma.event.count(),
|
||||
prisma.registration.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.registration.count(),
|
||||
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.ticket.count(),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
|
||||
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true } }),
|
||||
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
|
||||
]);
|
||||
const campusesOn = await campusesEnabled().catch(() => false);
|
||||
const campusCount = campusesOn ? await prisma.campus.count({ where: { archivedAt: null } }) : 0;
|
||||
return {
|
||||
users,
|
||||
events: { active: activeEvents, upcoming: upcomingEvents, total: totalEvents },
|
||||
registrations: { last30d: regs30, total: regsTotal },
|
||||
tickets: { last30d: tickets30, total: ticketsTotal },
|
||||
checkIns: { last30d: checkIns30 },
|
||||
// Messages aren't logged one by one, so there's nothing to count yet.
|
||||
messages: null,
|
||||
paymentProviders: providers,
|
||||
campuses: campusesOn,
|
||||
campusCount,
|
||||
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
|
||||
};
|
||||
}
|
||||
|
||||
/** What the install says about itself when registering. */
|
||||
async function installInfo() {
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
const [name, url, waConfigured, providers, campusesOn] = await Promise.all([
|
||||
getSetting('org_name', ''),
|
||||
getSetting('app_base_url', process.env.FRONTEND_URL || ''),
|
||||
require('../utils/whatsapp').isConfigured().catch(() => false),
|
||||
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
|
||||
require('../utils/campuses').campusesEnabled().catch(() => false),
|
||||
]);
|
||||
return {
|
||||
name: name || 'Cross Events',
|
||||
url: String(url || process.env.FRONTEND_URL || '').split(',')[0].trim().replace(/\/$/, ''),
|
||||
version: APP_VERSION,
|
||||
timezone: process.env.APP_TIMEZONE || 'Africa/Johannesburg',
|
||||
server: { node: process.versions.node, postgres: await postgresVersion(), os: `${os.platform()} ${os.release()}` },
|
||||
features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: campusesOn },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { APP_VERSION, incomeBand, health, stats, installInfo };
|
||||
+182
-3
@@ -156,7 +156,38 @@ app.use('/api/webhooks', webhookRoutes);
|
||||
// Apply JSON body parser for all non-webhook routes
|
||||
app.use(express.json());
|
||||
|
||||
// Count server errors (any 5xx response) for the Hub heartbeat.
|
||||
app.use((req, res, next) => {
|
||||
res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); });
|
||||
next();
|
||||
});
|
||||
|
||||
// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js).
|
||||
app.use(require('./hub/readOnly').licenceReadOnly);
|
||||
|
||||
// Health for monitoring (and the Hub): no secrets, no personal information.
|
||||
app.get('/health', async (req, res) => {
|
||||
const report = require('./hub/report');
|
||||
let db = true;
|
||||
try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; }
|
||||
const health = db ? await report.health().catch(() => null) : null;
|
||||
res.status(db ? 200 : 503).json({
|
||||
status: db ? 'ok' : 'error',
|
||||
version: report.APP_VERSION,
|
||||
db,
|
||||
jobs: health?.jobs || null,
|
||||
lastBackupAt: health?.lastBackupAt || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Use routes
|
||||
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);
|
||||
@@ -492,13 +523,13 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID',
|
||||
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID (with scopedCampuses)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }},
|
||||
{ status:404, desc:'Not found', body:{ message:'User not found' }},
|
||||
]},
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)',
|
||||
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role). scopedCampusIds limits a supervisor/staff member to those campuses (empty = every campus; audited)',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }},
|
||||
responses:[
|
||||
@@ -556,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' }]}]},
|
||||
@@ -886,6 +921,147 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||||
]},
|
||||
|
||||
{ title: 'Hub and licence', base: '/api/hub', endpoints: [
|
||||
{ method:'GET', path:'/health', auth:'public', desc:'Status, version, database, background jobs and last backup (503 if the database is down)',
|
||||
responses:[{ status:200, desc:'Healthy', body:{ status:'ok', version:'1.41.0', db:true, jobs:{ mode:'queue', failedLast24h:0 }, lastBackupAt:'2026-10-26T00:00:12.000Z' }}]},
|
||||
{ method:'GET', path:'/api/hub/status', auth:'admin', desc:'Licence state (active, grace, suspended, revoked, unlicensed), Hub connection, version and update',
|
||||
responses:[{ status:200, desc:'Success', body:{ state:'grace', readOnly:false, daysLeft:9, connected:true, version:'1.41.0', latestVersion:'1.42.0', updateAvailable:true }}]},
|
||||
{ method:'POST', path:'/api/hub/connect', auth:'admin', desc:'Connect to the Cross Events Hub with a licence key (also the /setup licence step)',
|
||||
request:{ body:{ licenceKey:'CEH-7K2M-9QXA-P4RT-WD8C' } },
|
||||
responses:[{ status:200, desc:'Connected (status as above)' }, { status:404, desc:'Unknown key', body:{ code:'LICENCE_NOT_FOUND', message:'That licence key was not found.' }}]},
|
||||
{ method:'POST', path:'/api/hub/heartbeat', auth:'admin', desc:'Check in with the Hub now (picks up licence changes)',
|
||||
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: [
|
||||
{ method:'GET', path:'/api/campuses', auth:'Public', desc:'Whether campuses are on, and the active campuses in order (empty while off)',
|
||||
responses:[{ status:200, desc:'Success', body:{ enabled:true, campuses:[{ id:'c_1', name:'Henley Campus', slug:'henley-campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall', order:0 }] }}]},
|
||||
{ method:'GET', path:'/api/campuses/by-slug/:slug', auth:'Public', desc:'One active campus, for its /campus/<slug> page (404 when archived or campuses are off)' },
|
||||
{ method:'GET', path:'/api/campuses/admin', auth:'admin', desc:'All campuses with archived ones, event and home-campus counts, and daily-summary recipients' },
|
||||
{ method:'PUT', path:'/api/campuses/settings', auth:'admin', desc:'Turn campuses on or off (off hides campus fields and filters; nothing is deleted). Audited as campuses_toggled',
|
||||
request:{ body:{ enabled:true } }},
|
||||
{ method:'POST', path:'/api/campuses', auth:'admin', desc:'Add a campus (slug made from the name unless given). 409 if the slug is taken',
|
||||
request:{ body:{ name:'Henley Campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall' } }},
|
||||
{ method:'PUT', path:'/api/campuses/:id', auth:'admin', desc:'Change a campus, its daily-summary recipients (staff only) and archived state',
|
||||
request:{ body:{ name:'Henley Campus', notifyRecipientIds:['u_1'], archived:false } }},
|
||||
{ method:'PUT', path:'/api/campuses/order', auth:'admin', desc:'Put the campuses in this order', request:{ body:{ ids:['c_2','c_1'] } }},
|
||||
{ method:'DELETE', path:'/api/campuses/:id', auth:'admin', desc:'Delete a campus with no events (409 CAMPUS_HAS_EVENTS otherwise — archive it)' },
|
||||
{ method:'GET', path:'/api/campuses/:id/audience', auth:'supervisor+', desc:'"Everyone at campus X" for broadcasts: home campus X, plus with ?registrants=true people registered for its events in the last 12 months' },
|
||||
]},
|
||||
|
||||
{ title: 'Kids check-in', base: '/api/kids', endpoints: [
|
||||
{ method:'GET', path:'/api/kids/me', auth:'user', desc:'My family (children, authorised adults, parents), the family QR token, live pickup codes and pages from the last 24 hours' },
|
||||
{ method:'POST', path:'/api/kids/me', auth:'user', desc:'Start my family', request:{ body:{ name:'The Smith family' } } },
|
||||
{ method:'PUT', path:'/api/kids/me', auth:'user', desc:'Rename my family' },
|
||||
{ method:'DELETE', path:'/api/kids/me', auth:'user', desc:'Leave my family (the last parent leaving deletes it with its children)' },
|
||||
{ method:'POST', path:'/api/kids/me/children', auth:'user', desc:'Add a child (allergies, medical and custody notes are stored encrypted)',
|
||||
request:{ body:{ firstName:'Mia', lastName:'Smith', dateOfBirth:'2021-03-04', allergies:'Peanuts', medicalNotes:'', custodyNotes:'' } } },
|
||||
{ method:'PUT', path:'/api/kids/me/children/:id', auth:'user', desc:'Change a child (custody note changes are audited)' },
|
||||
{ method:'DELETE', path:'/api/kids/me/children/:id', auth:'user', desc:'Remove a child' },
|
||||
{ method:'POST', path:'/api/kids/me/adults', auth:'user', desc:'Add an authorised adult (no account needed; up to 10)', request:{ body:{ name:'Mary Smith', phone:'0821234567', relationship:'Gran' } } },
|
||||
{ method:'PUT', path:'/api/kids/me/adults/:id', auth:'user', desc:'Change an authorised adult' },
|
||||
{ method:'DELETE', path:'/api/kids/me/adults/:id', auth:'user', desc:'Remove an authorised adult' },
|
||||
{ method:'GET', path:'/api/kids/setup-events', auth:'supervisor+', desc:'Upcoming events to set kids check-in up for (campus-scoped)' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Kids check-in settings, the main tickets that can be the kids ticket and the allocation sets' },
|
||||
{ method:'PUT', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Switch kids check-in on/off and choose the kids ticket and rooms ("new" makes a free "Kids check-in" ticket or a "Kids rooms" set). Audited as kids_settings_updated',
|
||||
request:{ body:{ enabled:true, optionId:'new', roomSetId:'new' } } },
|
||||
{ method:'GET', path:'/api/kids/events', auth:'staff+', desc:'Events with kids check-in from the last week on' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/board', auth:'staff+', desc:'Rooms (counts, leaders on duty, full/over ratio), check-ins with allergies, medical and custody notes, and alerts for today\'s occasion. ?log=1 writes the access log' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families?q=', auth:'staff+', desc:'Find families by family, child or parent name, email or phone' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families', auth:'staff+', desc:'New family at the desk: the parent\'s account is matched by email/phone or created inactive',
|
||||
request:{ body:{ name:'The Smith family', parent:{ name:'Anna Smith', phone:'0821234567', email:'' }, children:[{ firstName:'Mia', dateOfBirth:'2021-03-04' }] } } },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families/qr/:token', auth:'staff+', desc:'The family for a scanned family QR code' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/families/:familyId', auth:'staff+', desc:'One family' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/parents', auth:'staff+', desc:'Add a parent to a family' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/children', auth:'staff+', desc:'Add a child (custody notes: supervisors only)' },
|
||||
{ method:'PUT', path:'/api/kids/events/:eventId/families/:familyId/children/:childId', auth:'staff+', desc:'Change a child' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/adults', auth:'staff+', desc:'Add an authorised adult' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/check-in', auth:'staff+', desc:'Check children in: one pickup code per family per occasion, a free place on the kids ticket the first time, a ticket usage each time. Sends the code to the parents (and optionally an authorised adult)',
|
||||
request:{ body:{ familyId:'f_1', children:[{ childId:'c_1', roomId:'g_1' }], sendToAdultId:null } },
|
||||
responses:[{ status:201, desc:'Checked in', body:{ code:'K7P2', qr:'KIDS-K7P2', checkIns:[{ id:'k_1', name:'Mia Smith', room:{ id:'g_1', name:'Toddlers' }, sticker:'ALLERGY: PEANUTS' }] } }] },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/code/:code', auth:'staff+', desc:'The children on a pickup code (for confirming who is collected)' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/check-out', auth:'staff+', desc:'Check out with the pickup code. 404 WRONG_CODE raises a room alert (audited as kids_wrong_code)',
|
||||
request:{ body:{ code:'K7P2', collectedBy:'Anna Smith', checkInIds:['k_1'] } } },
|
||||
{ method:'POST', path:'/api/kids/check-ins/:id/override', auth:'staff+', desc:'Check one child out without the code; reason and who collected required. Audited as kids_checkout_override' },
|
||||
{ method:'POST', path:'/api/kids/check-ins/:id/page', auth:'staff+', desc:'Ask the parents to come to the room (WhatsApp/email; shows on their Kids page)' },
|
||||
{ method:'PUT', path:'/api/kids/check-ins/:id/room', auth:'staff+', desc:'Move a child to another room' },
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/duty', auth:'staff+', desc:'"I\'m on duty" in a room (on:false to go off), for the ratio' },
|
||||
{ method:'POST', path:'/api/kids/alerts/:id/resolve', auth:'staff+', desc:'Mark an alert as dealt with' },
|
||||
{ method:'GET', path:'/api/kids/events/:eventId/offline?deviceId=', auth:'staff+', desc:'Offline list for a desk: families, rooms, who is in, and 20 pickup codes reserved for the device' },
|
||||
{ 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:[] }}]},
|
||||
@@ -1132,7 +1308,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion. ?campus=<id|slug|all> for one campus or church-wide events only' },
|
||||
]},
|
||||
{ title: 'Backups', base: '/api/backups', endpoints: [
|
||||
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
|
||||
@@ -1533,6 +1709,9 @@ app.listen(PORT, () => {
|
||||
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
|
||||
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
|
||||
|
||||
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
|
||||
require('./hub/client').heartbeatSoon();
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -56,6 +68,10 @@ const JOBS = [
|
||||
name: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
|
||||
run: () => require('../utils/privacy').remindDueRequests(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'hub-heartbeat', label: 'Cross Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
|
||||
run: () => require('../hub/client').heartbeat(),
|
||||
},
|
||||
{
|
||||
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
|
||||
run: () => require('../utils/retention').runRetention(new Date()),
|
||||
@@ -243,8 +259,14 @@ async function runNow(name) {
|
||||
async function jobStatus(prisma) {
|
||||
const recent = new Map();
|
||||
if (mode === 'queue') {
|
||||
// pg-boss columns are timestamptz, and Prisma's pg adapter drops their offset in raw queries
|
||||
// (10:55+02 came back as 10:55Z). As plain UTC timestamps they come back right.
|
||||
const rows = await prisma.$queryRawUnsafe(`
|
||||
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
|
||||
SELECT name, state,
|
||||
created_on AT TIME ZONE 'UTC' AS "createdOn",
|
||||
started_on AT TIME ZONE 'UTC' AS "startedOn",
|
||||
completed_on AT TIME ZONE 'UTC' AS "completedOn",
|
||||
output
|
||||
FROM (
|
||||
SELECT name, state, created_on, started_on, completed_on, output,
|
||||
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -5,18 +5,19 @@ const {
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/my-groups', protect, staff, getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, getBoard);
|
||||
router.post('/sets', protect, supervisor, createSet);
|
||||
router.put('/sets/:id', protect, supervisor, updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, deleteGroup);
|
||||
router.get('/my-groups', protect, staff, campusIndependent('only the groups the signed-in person leads'), getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getBoard);
|
||||
router.post('/sets', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createSet);
|
||||
router.put('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@@ -13,6 +14,6 @@ const trackLimiter = rateLimit({
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, getFunnel);
|
||||
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,20 +6,21 @@ const {
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
} = require('../controllers/attendanceController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Certificates — fixed paths first so they aren't taken as an :eventId
|
||||
router.get('/certificates/verify/:code', verifyCertificate);
|
||||
router.get('/certificates/mine', protect, myCertificates);
|
||||
router.post('/certificates/preview', protect, supervisor, previewCertificate);
|
||||
router.post('/certificates/preview', protect, supervisor, campusIndependent('previews a certificate template from the request body'), previewCertificate);
|
||||
router.get('/certificates/:id/pdf', protect, downloadCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
|
||||
|
||||
// Attendance register
|
||||
router.get('/:eventId', protect, staff, getRegister);
|
||||
router.get('/:eventId/export', protect, staff, exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
|
||||
router.get('/:eventId', protect, staff, scopeEvent(), getRegister);
|
||||
router.get('/:eventId/export', protect, staff, scopeEvent(), exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, scopeEvent(), markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, scopeEvent(), issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, scopeEvent(), sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, scopeEvent(), downloadEventCertificates);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scheduleAutomations);
|
||||
router.post('/schedule', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, setBanner);
|
||||
router.post('/', protect, supervisor, unscopedOnly, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, previewBroadcast);
|
||||
router.post('/send', protect, supervisor, sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), previewBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, createBundle);
|
||||
router.put('/:id', protect, supervisor, updateBundle);
|
||||
router.delete('/:id', protect, supervisor, deleteBundle);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), createBundle);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), updateBundle);
|
||||
router.delete('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus,
|
||||
getCampusAudience,
|
||||
} = require('../controllers/campusController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Multi-campus (utils/campuses.js). The public list is empty while campuses are off.
|
||||
router.get('/', getCampuses);
|
||||
router.get('/by-slug/:slug', getCampusBySlug);
|
||||
router.get('/admin', protect, admin, getCampusesAdmin);
|
||||
router.put('/settings', protect, admin, updateCampusSettings);
|
||||
router.put('/order', protect, admin, reorderCampuses);
|
||||
router.post('/', protect, admin, createCampus);
|
||||
router.put('/:id', protect, admin, updateCampus);
|
||||
router.delete('/:id', protect, admin, deleteCampus);
|
||||
router.get('/:id/audience', protect, supervisor, scopeList, getCampusAudience);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,11 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
|
||||
router.get('/audit', protect, supervisor, scopeList, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCashup);
|
||||
router.get('/event/:eventId/cash-by-user', protect, supervisor, scopeEvent({ need: 'manage' }), getCashByRecordedUser);
|
||||
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, scopeEvent({ need: 'manage' }), savePersonCash);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
|
||||
@@ -3,15 +3,16 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
|
||||
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
|
||||
router.get('/events/:eventId/costs', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, validateCode);
|
||||
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, listCoupons);
|
||||
router.post('/', protect, supervisor, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, getCoupon);
|
||||
router.put('/:id', protect, supervisor, updateCoupon);
|
||||
router.get('/', protect, supervisor, scopeList, listCoupons);
|
||||
router.post('/', protect, supervisor, scopeList, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, scopeList, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, scopeList, getCoupon);
|
||||
router.put('/:id', protect, supervisor, scopeList, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
@@ -29,6 +30,7 @@ const {
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
@@ -49,40 +51,43 @@ router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, createEvent);
|
||||
router.post('/', protect, supervisor, scopeList, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, updateEvent)
|
||||
.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, getEventNotifyRecipients)
|
||||
.put(protect, supervisor, updateEventNotifyRecipients);
|
||||
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
|
||||
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
|
||||
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
|
||||
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, createEventOption);
|
||||
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, updateEventOption)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
|
||||
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, updateOptionVariant)
|
||||
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,13 +2,14 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
|
||||
router.get('/summary', protect, supervisor, scopeList, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, scopeEvent({ need: 'manage' }), sendNow);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,8 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, listFormResponses);
|
||||
router.get('/responses', protect, staff, scopeList, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
|
||||
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
|
||||
// admin token counts, so the licence step can run during /setup.
|
||||
router.get('/status', protect, admin, getStatus);
|
||||
router.post('/connect', protect, admin, connect);
|
||||
router.post('/heartbeat', protect, admin, heartbeatNow);
|
||||
router.post('/update-request', protect, admin, requestUpdate);
|
||||
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
|
||||
router.post('/update-snooze', protect, admin, snoozeUpdate);
|
||||
// The provider's notices: site admins and supervisors see them as a banner.
|
||||
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,47 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/kidsController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Kids (any signed-in parent; only their own family)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.post('/me', protect, c.createMine);
|
||||
router.put('/me', protect, c.renameMine);
|
||||
router.delete('/me', protect, c.leaveMine);
|
||||
router.post('/me/children', protect, c.saveMyChild);
|
||||
router.put('/me/children/:id', protect, c.saveMyChild);
|
||||
router.delete('/me/children/:id', protect, c.deleteMyChild);
|
||||
router.post('/me/adults', protect, c.saveMyAdult);
|
||||
router.put('/me/adults/:id', protect, c.saveMyAdult);
|
||||
router.delete('/me/adults/:id', protect, c.deleteMyAdult);
|
||||
|
||||
// Setup (supervisors)
|
||||
router.get('/setup-events', protect, supervisor, scopeList, c.setupEvents);
|
||||
router.get('/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
|
||||
// The desk and the rooms (staff). Families are church-wide; staff reach them through an event
|
||||
// they can work on.
|
||||
router.get('/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/events/:eventId/board', protect, staff, scopeEvent(), c.getBoard);
|
||||
router.get('/events/:eventId/families', protect, staff, scopeEvent(), c.searchFamilies);
|
||||
router.post('/events/:eventId/families', protect, staff, scopeEvent(), c.createFamilyAtDesk);
|
||||
router.get('/events/:eventId/families/qr/:token', protect, staff, scopeEvent(), c.familyByQr);
|
||||
router.get('/events/:eventId/families/:familyId', protect, staff, scopeEvent(), c.getFamily);
|
||||
router.post('/events/:eventId/families/:familyId/parents', protect, staff, scopeEvent(), c.addParentAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/children', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.put('/events/:eventId/families/:familyId/children/:childId', protect, staff, scopeEvent(), c.saveChildAtDesk);
|
||||
router.post('/events/:eventId/families/:familyId/adults', protect, staff, scopeEvent(), c.addAdultAtDesk);
|
||||
router.post('/events/:eventId/check-in', protect, staff, scopeEvent(), c.checkIn);
|
||||
router.post('/events/:eventId/check-out', protect, staff, scopeEvent(), c.checkOut);
|
||||
router.get('/events/:eventId/code/:code', protect, staff, scopeEvent(), c.lookupCode);
|
||||
router.post('/events/:eventId/duty', protect, staff, scopeEvent(), c.setDuty);
|
||||
router.get('/events/:eventId/offline', protect, staff, scopeEvent(), c.offlineSnapshot);
|
||||
router.post('/events/:eventId/sync', protect, staff, scopeEvent(), c.syncOffline);
|
||||
router.post('/check-ins/:id/override', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.overrideCheckOut);
|
||||
router.post('/check-ins/:id/page', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.pageParent);
|
||||
router.put('/check-ins/:id/room', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.moveRoom);
|
||||
router.post('/alerts/:id/resolve', protect, staff, scopeEvent({ resolve: 'kidsAlert' }), c.resolveAlert);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -17,32 +17,38 @@ const {
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
|
||||
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
|
||||
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
|
||||
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/', protect, supervisor, paymentTarget, createPayment);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
router.post('/pay/:id', choosePayProvider);
|
||||
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
|
||||
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
|
||||
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
// Older names, kept for existing clients
|
||||
router.post('/yoco-checkout', protect, createCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/', protect, supervisor, scopeList, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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);
|
||||
|
||||
@@ -2,19 +2,20 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
const { unscopedOnly } = require('../utils/campusScope');
|
||||
|
||||
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
|
||||
|
||||
// GET all payment provider transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllProviderTransactions);
|
||||
router.get('/', protect, supervisor, unscopedOnly, getAllProviderTransactions);
|
||||
|
||||
// GET all unreconciled transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
|
||||
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
|
||||
|
||||
// POST reconcile a transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
|
||||
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
|
||||
|
||||
// POST ignore a transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
|
||||
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -17,29 +17,34 @@ const {
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = 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).
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
|
||||
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
|
||||
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
|
||||
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, getRegistrations);
|
||||
router.put('/:id', protect, staff, updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, createManualRegistration);
|
||||
router.get('/', protect, staff, scopeList, getRegistrations);
|
||||
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,14 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, listLocks);
|
||||
router.post('/', protect, staff, createLock);
|
||||
router.get('/reports', protect, supervisor, listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, forceRelease);
|
||||
router.delete('/:id', protect, staff, releaseLock);
|
||||
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
|
||||
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
|
||||
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
|
||||
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
|
||||
router.get('/', protect, supervisor, scopeList, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases } = require('../controllers/seasonPassController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
const SPANS = campusIndependent('a pass can span campuses; each event on it is checked against the person\'s campuses in the controller');
|
||||
|
||||
router.get('/mine', protect, myPurchases);
|
||||
router.post('/:id/buy', protect, buy);
|
||||
router.post('/purchases/:id/checkout', protect, payPurchase);
|
||||
|
||||
router.get('/', protect, supervisor, SPANS, listPasses);
|
||||
router.post('/', protect, supervisor, SPANS, createPass);
|
||||
router.put('/:id', protect, supervisor, SPANS, updatePass);
|
||||
router.delete('/:id', protect, supervisor, SPANS, deletePass);
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,10 +7,11 @@ const {
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
router.get('/', protect, staff, getSections);
|
||||
router.post('/', protect, supervisor, createSection);
|
||||
router.get('/', protect, staff, scopeList, getSections);
|
||||
router.post('/', protect, supervisor, scopeEvent({ from: req => req.body?.eventId, need: 'manage' }), createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, updateSection);
|
||||
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
|
||||
|
||||
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;
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, getPool);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,16 +2,17 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
|
||||
// Staff never sees financial data, so this has no staff-accessible route.
|
||||
router.get('/overview', protect, supervisor, getOverviewStats);
|
||||
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -22,31 +22,34 @@ const {
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, cancelTransferHandler);
|
||||
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, getScanStats);
|
||||
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, generateTickets);
|
||||
router.get('/', protect, supervisor, getTickets);
|
||||
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
|
||||
router.get('/', protect, supervisor, scopeList, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,12 +3,13 @@ const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
getMyActivity,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
@@ -51,9 +52,9 @@ router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, getUsers);
|
||||
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, checkUserExists);
|
||||
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent } = require('../utils/campusScope');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
@@ -10,10 +11,10 @@ const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { messag
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
|
||||
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeBroadcast } = require('../utils/campusScope');
|
||||
|
||||
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
|
||||
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -15,6 +15,9 @@ const ACCESS_ACTIONS = {
|
||||
medical_list: 'Medical list viewed',
|
||||
backup_download: 'Database backup downloaded',
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Campus-scoped staff (1.44.0). A supervisor or staff member can be limited to some campuses
|
||||
* (Admin → Users → "Limit to campuses"). They then see their campuses' events plus events for
|
||||
* all campuses (Event.campusId null) — and can work on those church-wide events (view, scan,
|
||||
* door payments, manual registrations) but not set them up, cash them up or refund them.
|
||||
*
|
||||
* Unscoped (no limits) = everything, as before. Admins are never scoped, and scopes are ignored
|
||||
* while campuses are off (Site settings → Campuses).
|
||||
*
|
||||
* How a route is checked — every staff/supervisor route carries one of these markers, and
|
||||
* tests/campusScopeRoutes.test.js fails on any route that doesn't:
|
||||
* scopeEvent({ resolve, from, need }) the route works on one event (or a record of one)
|
||||
* scopeList the controller filters (listWhere) or checks it itself
|
||||
* unscopedOnly admins and unscoped supervisors only
|
||||
* campusIndependent('reason') nothing to do with any event's campus
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { campusesEnabled } = require('./campuses');
|
||||
|
||||
const STAFF_ROLES = new Set(['supervisor', 'staff']);
|
||||
|
||||
/**
|
||||
* The scope of the signed-in user: null (unscoped) or { campusIds, names }. Worked out once
|
||||
* per request.
|
||||
*/
|
||||
async function scopeFor(req) {
|
||||
if (req._campusScope !== undefined) return req._campusScope;
|
||||
let scope = null;
|
||||
if (req.user && STAFF_ROLES.has(req.user.role) && await campusesEnabled()) {
|
||||
const row = await prisma.user.findUnique({ where: { id: req.user.id }, select: { scopedCampuses: { select: { id: true, name: true } } } });
|
||||
const campuses = row?.scopedCampuses || [];
|
||||
if (campuses.length) scope = { campusIds: campuses.map(c => c.id), names: campuses.map(c => c.name) };
|
||||
}
|
||||
req._campusScope = scope;
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** The `where` on events for a scope: its campuses plus church-wide events. {} when unscoped. */
|
||||
function eventWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] };
|
||||
}
|
||||
|
||||
/** The `where` on events a scope may manage: only its campuses' (not church-wide). {} when unscoped. */
|
||||
function manageWhere(scope) {
|
||||
if (!scope) return {};
|
||||
return { campusId: { in: scope.campusIds } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, through a relation path: relWhere(scope, 'event') → { event: {...} },
|
||||
* relWhere(scope, 'registration.event') → { registration: { event: {...} } }.
|
||||
*/
|
||||
function relWhere(scope, path) {
|
||||
if (!scope) return {};
|
||||
return path.split('.').reverse().reduce((inner, key) => ({ [key]: inner }), eventWhere(scope));
|
||||
}
|
||||
|
||||
/** Can this scope see/work on an event at `campusId` (null = all campuses)? */
|
||||
function canView(scope, campusId) {
|
||||
return !scope || campusId == null || scope.campusIds.includes(campusId);
|
||||
}
|
||||
|
||||
/** Can this scope set up, cash up or refund an event at `campusId`? Not church-wide ones. */
|
||||
function canManage(scope, campusId) {
|
||||
return !scope || (campusId != null && scope.campusIds.includes(campusId));
|
||||
}
|
||||
|
||||
// id → { id, campusId, campusName } of the event a record belongs to, or null if there's no such
|
||||
// record. The event select is shared so every resolver answers the same shape.
|
||||
// `owners` are the users the record belongs to, for scopeEvent({ ownerOk }).
|
||||
const EV = { select: { id: true, campusId: true, campus: { select: { name: true } } } };
|
||||
const shape = (ev, owners = []) => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null, owners: owners.filter(Boolean) } : null);
|
||||
|
||||
const RESOLVERS = {
|
||||
event: async id => shape(await prisma.event.findUnique({ where: { id }, ...EV })),
|
||||
registration: async id => {
|
||||
const r = await prisma.registration.findUnique({ where: { id }, select: { userId: true, event: EV } });
|
||||
return shape(r?.event, [r?.userId]);
|
||||
},
|
||||
ticket: async id => {
|
||||
const t = await prisma.ticket.findUnique({ where: { id }, select: { userId: true, holderUserId: true, event: EV } });
|
||||
return shape(t?.event, [t?.userId, t?.holderUserId]);
|
||||
},
|
||||
ticketQr: async qrCode => shape((await prisma.ticket.findUnique({ where: { qrCode }, select: { event: EV } }))?.event),
|
||||
payment: async id => {
|
||||
const p = await prisma.payment.findUnique({ where: { id }, select: { userId: true, event: EV, registration: { select: { event: EV } } } });
|
||||
return shape(p?.event || p?.registration?.event, [p?.userId]);
|
||||
},
|
||||
eventOption: async id => shape((await prisma.eventOption.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
optionVariant: async id => shape((await prisma.optionVariant.findUnique({ where: { id }, select: { eventOption: { select: { event: EV } } } }))?.eventOption?.event),
|
||||
bundle: async id => shape((await prisma.eventBundle.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationSet: async id => shape((await prisma.allocationSet.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
allocationGroup: async id => shape((await prisma.allocationGroup.findUnique({ where: { id }, select: { set: { select: { event: EV } } } }))?.set?.event),
|
||||
waitlistEntry: async id => shape((await prisma.waitlistEntry.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
scanLock: async id => shape((await prisma.scanLock.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
syncReport: async id => shape((await prisma.offlineSyncReport.findUnique({ where: { id }, select: { scanLock: { select: { event: EV } } } }))?.scanLock?.event),
|
||||
section: async id => shape((await prisma.section.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
certificate: async id => shape((await prisma.certificate.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
consentRequest: async id => {
|
||||
const c = await prisma.consentRequest.findUnique({ where: { id }, select: { event: EV, attendee: { select: { registration: { select: { userId: true } } } } } });
|
||||
return shape(c?.event, [c?.attendee?.registration?.userId]);
|
||||
},
|
||||
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. */
|
||||
async function eventOf(kind, id) {
|
||||
const resolve = RESOLVERS[kind];
|
||||
if (!resolve) throw new Error(`No campus resolver for "${kind}"`);
|
||||
if (!id) return null;
|
||||
return resolve(String(id));
|
||||
}
|
||||
|
||||
const NOT_FOUND = { message: 'Not found' };
|
||||
const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and supervisors for every campus can change it.';
|
||||
|
||||
/**
|
||||
* Check a scoped user against one event, found from the request. Out of scope → 404 (another
|
||||
* campus's records don't leak); a church-wide event when `need: 'manage'` → 403. Records that
|
||||
* don't exist pass through so the controller answers as it always has.
|
||||
* resolve which RESOLVERS entry turns the id into its event (default 'event')
|
||||
* from req → id (default req.params.eventId || req.params.id)
|
||||
* need 'view' (default) or 'manage'
|
||||
* ownerOk let the record's own user through (a staff member's own registration for another
|
||||
* campus's event) — for routes open to the owner as well as to staff
|
||||
* nameCampus answer 403 naming the event's campus instead of 404 (the ticket scanner: the
|
||||
* person at the door needs to know where the ticket belongs)
|
||||
*/
|
||||
function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req.params.id, need = 'view', ownerOk = false, nameCampus = false } = {}) {
|
||||
if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const ev = await eventOf(resolve, from(req));
|
||||
if (!ev) return next();
|
||||
if (ownerOk && ev.owners.includes(req.user.id)) return next();
|
||||
if (!canView(scope, ev.campusId)) {
|
||||
if (nameCampus) return res.status(403).json({ code: 'OTHER_CAMPUS', message: `This ticket is for an event at ${ev.campusName || 'another campus'}.` });
|
||||
return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
req.scopedEvent = ev;
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `event:${resolve}:${need}${ownerOk ? ':owner-ok' : ''}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like scopeEvent, for records named in the request body: `targets` is [[field, resolver], …]
|
||||
* (e.g. [['registrationId', 'registration'], ['eventId', 'event']]). Every target that's given
|
||||
* is checked. With `orphan: 'deny'`, a scoped user is refused when none of them leads to an
|
||||
* event (a donation not linked to any event is for admins and unscoped supervisors only).
|
||||
*/
|
||||
function scopeBody(targets, { need = 'view', orphan = 'pass' } = {}) {
|
||||
for (const [, resolve] of targets) if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
let found = false;
|
||||
for (const [field, resolve] of targets) {
|
||||
const value = req.body?.[field];
|
||||
// A list (e.g. ticketIds) checks every id in it.
|
||||
for (const id of (Array.isArray(value) ? value : [value]).filter(Boolean).slice(0, 500)) {
|
||||
const ev = await eventOf(resolve, id);
|
||||
if (!ev) continue;
|
||||
found = true;
|
||||
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
|
||||
}
|
||||
}
|
||||
if (!found && orphan === 'deny') {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: "Only admins and supervisors for every campus can work with payments that aren't for an event." });
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = `body:${targets.map(t => t.join('→')).join(',')}:${need}:${orphan}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/** Marker: the controller filters what it lists with listWhere(). */
|
||||
const scopeList = (req, res, next) => next();
|
||||
scopeList.campusScope = 'list';
|
||||
|
||||
/** Admins and unscoped supervisors/staff only. */
|
||||
const unscopedOnly = async (req, res, next) => {
|
||||
try {
|
||||
if (await scopeFor(req)) return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Only admins and supervisors for every campus can do this.' });
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
unscopedOnly.campusScope = 'unscoped-only';
|
||||
|
||||
/** Marker for a route that has nothing to do with any event's campus; the reason is required. */
|
||||
function campusIndependent(reason) {
|
||||
if (!reason) throw new Error('campusIndependent needs a reason');
|
||||
const mw = (req, res, next) => next();
|
||||
mw.campusScope = `independent: ${reason}`;
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a controller's `where` with a scope `where` without either overwriting the other's
|
||||
* OR/AND (spreading would): { AND: [where, extra] }, or `where` itself when there's no scope.
|
||||
*/
|
||||
function withScope(where, extra) {
|
||||
if (!extra || !Object.keys(extra).length) return where;
|
||||
if (!where || !Object.keys(where).length) return extra;
|
||||
return { AND: [where, extra] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts (email/WhatsApp, send/preview/schedule): a scoped supervisor may only message
|
||||
* people at their campuses (home campus) or attendees of events they can see, and can't add
|
||||
* free-typed addresses (`freeformField`: 'emails' or 'phones'). The placeholder event must be
|
||||
* one they can see.
|
||||
*/
|
||||
function scopeBroadcast({ freeformField }) {
|
||||
const mw = async (req, res, next) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
if (!scope) return next();
|
||||
const body = req.body || {};
|
||||
if (String(body[freeformField] || '').trim()) {
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Supervisors for some campuses can only message people from the list, not typed-in addresses.' });
|
||||
}
|
||||
if (body.eventId) {
|
||||
const ev = await eventOf('event', body.eventId);
|
||||
if (ev && !canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
const ids = Array.isArray(body.userIds) ? [...new Set(body.userIds.filter(x => typeof x === 'string' && x))] : [];
|
||||
if (ids.length) {
|
||||
const allowed = await prisma.user.count({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
OR: [
|
||||
{ homeCampusId: { in: scope.campusIds } },
|
||||
{ registrations: { some: { event: eventWhere(scope) } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (allowed !== ids.length) {
|
||||
const n = ids.length - allowed;
|
||||
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: `${n} of the people selected ${n === 1 ? "isn't" : "aren't"} at your campuses or registered for your events.` });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
};
|
||||
mw.campusScope = 'broadcast';
|
||||
return mw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupons: a scoped supervisor may see and change a coupon only when it's limited to events,
|
||||
* all at their campuses (a coupon for every event, or a church-wide event, is unscoped-only).
|
||||
* `campusIds` are the campuses of the coupon's events.
|
||||
*/
|
||||
function couponAllowed(scope, campusIds) {
|
||||
if (!scope) return true;
|
||||
return campusIds.length > 0 && campusIds.every(id => id != null && scope.campusIds.includes(id));
|
||||
}
|
||||
|
||||
/** The `where` for a list endpoint: relWhere through `path` ('' = the event itself). */
|
||||
async function listWhere(req, path = '') {
|
||||
const scope = await scopeFor(req);
|
||||
return path ? relWhere(scope, path) : eventWhere(scope);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
|
||||
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Multi-campus (Site settings → Campuses, off by default). Events belong to one campus, or to
|
||||
* all campuses when Event.campusId is null. While campuses are off, campus fields and filters
|
||||
* are ignored everywhere (the data stays, hidden). Campus-scoped staff come in 1.44.0.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
const SETTING_KEY = 'campuses_enabled';
|
||||
|
||||
// What the public site may see of a campus.
|
||||
const CAMPUS_PUBLIC_SELECT = { id: true, name: true, slug: true, address: true, colour: true, logoUrl: true, defaultLocation: true, contactName: true, contactEmail: true, contactPhone: true, order: true };
|
||||
// What an event carries about its campus (cards, tickets, lists).
|
||||
const EVENT_CAMPUS_SELECT = { id: true, name: true, slug: true, colour: true };
|
||||
|
||||
async function campusesEnabled() {
|
||||
return String(await getSetting(SETTING_KEY, 'false')).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/** "Hope Centre — Henley" → "hope-centre-henley". */
|
||||
function slugify(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const trimOrNull = (v, max = 200) => {
|
||||
if (v === undefined) return undefined;
|
||||
const s = String(v ?? '').trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check and clean the fields of a campus from a request body. Returns { data } or { error }.
|
||||
* `partial` (an update) only touches the fields that were sent.
|
||||
*/
|
||||
function cleanCampusInput(body = {}, { partial = false } = {}) {
|
||||
const data = {};
|
||||
if (!partial || body.name !== undefined) {
|
||||
const name = trimOrNull(body.name, 100);
|
||||
if (!name) return { error: 'Give the campus a name.' };
|
||||
data.name = name;
|
||||
}
|
||||
if (body.slug !== undefined || (!partial && data.name)) {
|
||||
const slug = slugify(body.slug || data.name);
|
||||
if (!slug) return { error: 'The web address needs letters or numbers.' };
|
||||
data.slug = slug;
|
||||
}
|
||||
for (const [key, max] of [['address', 300], ['contactName', 100], ['contactPhone', 40], ['defaultLocation', 300], ['logoUrl', 500]]) {
|
||||
const v = trimOrNull(body[key], max);
|
||||
if (v !== undefined) data[key] = v;
|
||||
}
|
||||
if (body.contactEmail !== undefined) {
|
||||
const email = trimOrNull(body.contactEmail, 200);
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'That contact email doesn\'t look right.' };
|
||||
data.contactEmail = email;
|
||||
}
|
||||
if (body.colour !== undefined) {
|
||||
const colour = trimOrNull(body.colour, 7);
|
||||
if (colour && !/^#[0-9a-fA-F]{6}$/.test(colour)) return { error: 'Use a colour like #1d4ed8.' };
|
||||
data.colour = colour ? colour.toLowerCase() : null;
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` for a campus filter on a list of events.
|
||||
* filter ''/undefined → no filter (church-wide)
|
||||
* filter 'all' → only events for all campuses
|
||||
* filter <campus id> → that campus's events; `withAllCampuses` also includes events for all
|
||||
* campuses (the public list: a church-wide camp shows at every campus)
|
||||
* Returns {} when campuses are off, so callers can spread it unconditionally.
|
||||
*/
|
||||
function campusWhere(filter, { enabled, withAllCampuses = false } = {}) {
|
||||
if (!enabled || !filter) return {};
|
||||
if (filter === 'all') return { campusId: null };
|
||||
return withAllCampuses ? { OR: [{ campusId: String(filter) }, { campusId: null }] } : { campusId: String(filter) };
|
||||
}
|
||||
|
||||
/** A campus by id or slug (archived ones too), or null. */
|
||||
async function findCampus(idOrSlug) {
|
||||
if (!idOrSlug) return null;
|
||||
const v = String(idOrSlug);
|
||||
return prisma.campus.findFirst({ where: { OR: [{ id: v }, { slug: v }] } });
|
||||
}
|
||||
|
||||
/** Resolve a filter value from a query string (id, slug or 'all') to what campusWhere takes. */
|
||||
async function resolveCampusFilter(value) {
|
||||
if (!value) return '';
|
||||
if (value === 'all') return 'all';
|
||||
const campus = await findCampus(value);
|
||||
// An unknown campus matches nothing rather than silently showing everything.
|
||||
return campus ? campus.id : '__none__';
|
||||
}
|
||||
|
||||
/**
|
||||
* The campus to save on an event from a request's `campusId` (''/null = all campuses).
|
||||
* Returns { campusId } (undefined = leave as is) or { error }. Ignored while campuses are off.
|
||||
* An archived campus can stay on an event that already has it, but can't be newly chosen.
|
||||
*/
|
||||
async function campusForEvent(value, { enabled, currentCampusId = null } = {}) {
|
||||
if (!enabled || value === undefined) return { campusId: undefined };
|
||||
if (value === null || value === '' || value === 'all') return { campusId: null };
|
||||
const campus = await prisma.campus.findUnique({ where: { id: String(value) } });
|
||||
if (!campus) return { error: 'That campus doesn\'t exist.' };
|
||||
if (campus.archivedAt && campus.id !== currentCampusId) return { error: `${campus.name} is archived — choose another campus.` };
|
||||
return { campusId: campus.id };
|
||||
}
|
||||
|
||||
const AUDIENCE_MONTHS = 12;
|
||||
|
||||
/**
|
||||
* The `where` for "everyone at campus X" (broadcasts): people whose home campus is X, plus —
|
||||
* with `includeRegistrants` — people with a registration for one of X's events in the last
|
||||
* 12 months. Active real accounts only. News consent is still checked when sending.
|
||||
*/
|
||||
function campusAudienceWhere(campusId, { includeRegistrants = false, now = new Date() } = {}) {
|
||||
const since = new Date(now);
|
||||
since.setMonth(since.getMonth() - AUDIENCE_MONTHS);
|
||||
const who = [{ homeCampusId: campusId }];
|
||||
if (includeRegistrants) {
|
||||
who.push({ registrations: { some: { createdAt: { gte: since }, status: { not: 'cancelled' }, event: { campusId } } } });
|
||||
}
|
||||
return {
|
||||
isActive: true,
|
||||
NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }],
|
||||
OR: who,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY, AUDIENCE_MONTHS, CAMPUS_PUBLIC_SELECT, EVENT_CAMPUS_SELECT,
|
||||
campusesEnabled, slugify, cleanCampusInput, campusWhere, findCampus, resolveCampusFilter, campusForEvent, campusAudienceWhere,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server errors (5xx responses) in the last 24 hours, for the Hub heartbeat. In memory per
|
||||
// process: a restart starts again from zero, which is fine for spotting a spike.
|
||||
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_KEPT = 10000;
|
||||
let times = [];
|
||||
|
||||
function recordError(now = Date.now()) {
|
||||
times.push(now);
|
||||
if (times.length > MAX_KEPT) times = times.slice(-MAX_KEPT);
|
||||
}
|
||||
|
||||
function countLast24h(now = Date.now()) {
|
||||
times = times.filter(t => now - t < WINDOW_MS);
|
||||
return times.length;
|
||||
}
|
||||
|
||||
module.exports = { recordError, countLast24h };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* Kids check-in (1.45.0). Children are checked in at a staff desk, fully digitally:
|
||||
*
|
||||
* - Families (parent accounts, children, authorised adults) are kept between events.
|
||||
* - A check-in gives the family a pickup code for the occasion — a session, or the day on a
|
||||
* single-date event — shared by the children checked in together. The parent sees it on
|
||||
* their phone (My account → Kids) and gets it by WhatsApp/email; anyone with the code can
|
||||
* collect. The desk shows a "write on the sticker" panel instead of printing labels.
|
||||
* - Each child gets a free place on the event's kids option the first time (registration,
|
||||
* attendee, ticket under the parent), and every check-in writes a TicketUsage — so the
|
||||
* attendance register, reports and campus scoping treat them like any other attendee.
|
||||
* - Rooms are the groups of the event's "kids rooms" allocation set, with an age range,
|
||||
* capacity and a ratio (children per leader on duty).
|
||||
*
|
||||
* Allergies, medical and custody notes are encrypted and never put in messages.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('./encryption');
|
||||
const sessionsUtil = require('./sessions');
|
||||
|
||||
// No 0/O or 1/I, so a code read out or copied onto a sticker can't be misread.
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 4;
|
||||
const OFFLINE_CODES = 20;
|
||||
const MAX_TEXT = 1000;
|
||||
// What a pickup QR holds: the prefix and the code.
|
||||
const QR_PREFIX = 'KIDS-';
|
||||
|
||||
function kidsError(message, statusCode = 400, code = undefined) {
|
||||
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function randomCode(length = CODE_LENGTH) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A typed or scanned code, cleaned up: "kids-k7p2" / " k7 p2 " → "K7P2"; null if it can't be one. */
|
||||
function normaliseCode(raw) {
|
||||
let s = String(raw || '').trim().toUpperCase();
|
||||
if (s.startsWith(QR_PREFIX)) s = s.slice(QR_PREFIX.length);
|
||||
s = s.replace(/[\s-]/g, '');
|
||||
if (s.length !== CODE_LENGTH || [...s].some(c => !CODE_ALPHABET.includes(c))) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Age in whole years on `on` (null without a date of birth). */
|
||||
function ageOn(dateOfBirth, on = new Date()) {
|
||||
if (!dateOfBirth) return null;
|
||||
const d = new Date(dateOfBirth), e = new Date(on);
|
||||
let age = e.getUTCFullYear() - d.getUTCFullYear();
|
||||
if (e.getUTCMonth() < d.getUTCMonth() || (e.getUTCMonth() === d.getUTCMonth() && e.getUTCDate() < d.getUTCDate())) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Whether a child of `age` fits a room's age range (no age or no range = fits). */
|
||||
function fitsRoom(room, age) {
|
||||
if (age == null) return true;
|
||||
if (room.minAge != null && age < room.minAge) return false;
|
||||
if (room.maxAge != null && age > room.maxAge) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The room to suggest for a child: rooms whose age range fits, not full, narrowest range
|
||||
* first (a 2–3 room beats a 0–12 room), then room order. `counts` = { roomId: children in }.
|
||||
* Returns null when nothing fits.
|
||||
*/
|
||||
function suggestRoom(rooms, age, counts = {}) {
|
||||
const span = r => (r.maxAge ?? 25) - (r.minAge ?? 0);
|
||||
const fits = rooms.filter(r => fitsRoom(r, age) && !(r.capacity && (counts[r.id] || 0) >= r.capacity));
|
||||
if (!fits.length) return null;
|
||||
return [...fits].sort((a, b) => (age == null ? 0 : span(a) - span(b)) || (a.order || 0) - (b.order || 0))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A room's state: children in, leaders on duty, how many leaders the ratio needs, and whether
|
||||
* it's full or over the ratio.
|
||||
*/
|
||||
function roomStatus(room, children, leaders) {
|
||||
const needed = room.ratio ? Math.ceil(children / room.ratio) : 0;
|
||||
return {
|
||||
children,
|
||||
leaders,
|
||||
leadersNeeded: needed,
|
||||
full: !!room.capacity && children >= room.capacity,
|
||||
overRatio: !!room.ratio && children > 0 && leaders * room.ratio < children,
|
||||
};
|
||||
}
|
||||
|
||||
/** The short alert copied onto a sticker: "ALLERGY: PEANUTS", "MEDICAL", or both; '' when none. */
|
||||
function stickerAlert(allergies, medicalNotes) {
|
||||
const parts = [];
|
||||
const a = String(allergies || '').trim();
|
||||
if (a) parts.push(`ALLERGY: ${a.toUpperCase().slice(0, 24)}${a.length > 24 ? '…' : ''}`);
|
||||
if (String(medicalNotes || '').trim()) parts.push('MEDICAL');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The occasion a check-in counts for: { occasion, sessionId }. On a multi-session event it's
|
||||
* today's session (or the one asked for, if it's open today); on a single-date event it's
|
||||
* today, while the event is on. Throws when check-in isn't open.
|
||||
*/
|
||||
function occasionFor(event, sessions, requestedSessionId = null, now = new Date()) {
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const { session, error } = sessionsUtil.scanSession(event, sessions || [], requestedSessionId || null, now);
|
||||
if (!session) throw kidsError(error || 'No session is open for check-in today.');
|
||||
return { occasion: session.id, sessionId: session.id };
|
||||
}
|
||||
const today = sessionsUtil.dayKey(now);
|
||||
if (today < sessionsUtil.dayKey(event.startDate) || today > sessionsUtil.dayKey(event.endDate)) {
|
||||
throw kidsError(`Check-in for this event is open on ${sessionsUtil.dayKey(event.startDate)}${sessionsUtil.dayKey(event.endDate) !== sessionsUtil.dayKey(event.startDate) ? ` to ${sessionsUtil.dayKey(event.endDate)}` : ''}.`);
|
||||
}
|
||||
return { occasion: today, sessionId: null };
|
||||
}
|
||||
|
||||
const seal = v => {
|
||||
const s = String(v ?? '').trim().slice(0, MAX_TEXT);
|
||||
return s ? encrypt(s) : null;
|
||||
};
|
||||
const open = v => (v ? decrypt(v) || '' : '');
|
||||
|
||||
function childName(c) {
|
||||
return [c.firstName, c.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/** A child for the API. `notes` adds medical and custody notes (staff and the child's parents). */
|
||||
function childView(c, { on = new Date(), notes = true } = {}) {
|
||||
const allergies = open(c.allergies);
|
||||
const medicalNotes = open(c.medicalNotes);
|
||||
return {
|
||||
id: c.id,
|
||||
firstName: c.firstName,
|
||||
lastName: c.lastName || '',
|
||||
name: childName(c),
|
||||
dateOfBirth: c.dateOfBirth,
|
||||
age: ageOn(c.dateOfBirth, on),
|
||||
gender: c.gender || null,
|
||||
allergies,
|
||||
hasMedical: !!medicalNotes,
|
||||
hasCustody: !!c.custodyNotes,
|
||||
...(notes ? { medicalNotes, custodyNotes: open(c.custodyNotes) } : {}),
|
||||
lastCheckInAt: c.lastCheckInAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Families ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FAMILY_INCLUDE = {
|
||||
members: { orderBy: { createdAt: 'asc' }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } },
|
||||
children: { orderBy: [{ dateOfBirth: 'asc' }, { firstName: 'asc' }] },
|
||||
adults: { orderBy: { createdAt: 'asc' } },
|
||||
};
|
||||
|
||||
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
|
||||
|
||||
function familyView(f, { notes = true, on = new Date() } = {}) {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parents: f.members.map(m => ({ userId: m.user.id, name: m.user.name, phone: m.user.phoneNumber || null, email: realEmail(m.user.email) })),
|
||||
children: f.children.map(c => childView(c, { notes, on })),
|
||||
adults: f.adults.map(a => ({ id: a.id, name: a.name, phone: a.phone || null, relationship: a.relationship || null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFamily(id, db = prisma) {
|
||||
const f = await db.family.findUnique({ where: { id: String(id || '') }, include: FAMILY_INCLUDE });
|
||||
if (!f) throw kidsError('Family not found', 404);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** The signed-in person's family, or null. */
|
||||
async function familyOfUser(userId, db = prisma) {
|
||||
const m = await db.familyMember.findUnique({ where: { userId }, select: { familyId: true } });
|
||||
return m ? db.family.findUnique({ where: { id: m.familyId }, include: FAMILY_INCLUDE }) : null;
|
||||
}
|
||||
|
||||
/** Create a family with `userId` as its first parent (a person is in one family at most). */
|
||||
async function createFamily({ name, userId }, db = prisma) {
|
||||
if (await db.familyMember.findUnique({ where: { userId } })) throw kidsError('This person is already a parent in a family.', 409);
|
||||
const clean = String(name || '').trim().slice(0, 100);
|
||||
if (!clean) throw kidsError('Enter a name for the family (e.g. "The Smith family").');
|
||||
const f = await db.family.create({ data: { name: clean, qrToken: crypto.randomBytes(16).toString('hex'), members: { create: { userId } } } });
|
||||
return loadFamily(f.id, db);
|
||||
}
|
||||
|
||||
function cleanChild(input, existing = null) {
|
||||
const data = {};
|
||||
const b = input || {};
|
||||
if (b.firstName !== undefined || !existing) {
|
||||
const first = String(b.firstName || '').trim().slice(0, 60);
|
||||
if (!first) throw kidsError('Enter the child\'s first name.');
|
||||
data.firstName = first;
|
||||
}
|
||||
if (b.lastName !== undefined) data.lastName = String(b.lastName || '').trim().slice(0, 60) || null;
|
||||
if (b.dateOfBirth !== undefined) {
|
||||
if (!b.dateOfBirth) data.dateOfBirth = null;
|
||||
else {
|
||||
const d = new Date(b.dateOfBirth);
|
||||
if (Number.isNaN(d.getTime()) || d > new Date() || ageOn(d) > 25) throw kidsError('Enter a valid date of birth.');
|
||||
data.dateOfBirth = d;
|
||||
}
|
||||
}
|
||||
if (b.gender !== undefined) data.gender = ['male', 'female'].includes(b.gender) ? b.gender : null;
|
||||
if (b.allergies !== undefined) data.allergies = seal(b.allergies);
|
||||
if (b.medicalNotes !== undefined) data.medicalNotes = seal(b.medicalNotes);
|
||||
if (b.custodyNotes !== undefined) data.custodyNotes = seal(b.custodyNotes);
|
||||
return data;
|
||||
}
|
||||
|
||||
function cleanAdult(input) {
|
||||
const name = String(input?.name || '').trim().slice(0, 100);
|
||||
if (!name) throw kidsError('Enter the adult\'s name.');
|
||||
const { normalizeZAPhone } = require('./whatsapp');
|
||||
const rawPhone = String(input?.phone || '').trim();
|
||||
const phone = rawPhone ? (normalizeZAPhone(rawPhone) || rawPhone.replace(/[^\d+]/g, '')).slice(0, 20) : null;
|
||||
return { name, phone, relationship: String(input?.relationship || '').trim().slice(0, 60) || null };
|
||||
}
|
||||
|
||||
/** Whether custody notes change between the stored child and the cleaned update. */
|
||||
function custodyChanged(existing, data) {
|
||||
return data.custodyNotes !== undefined && open(existing?.custodyNotes) !== open(data.custodyNotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Families matching a search: family name, a child's name, a parent's name, email or phone.
|
||||
* Children's names and allergies are encrypted only in part (allergies), so names search fine.
|
||||
*/
|
||||
async function searchFamilies(q, db = prisma, { limit = 20 } = {}) {
|
||||
const term = String(q || '').trim();
|
||||
if (term.length < 2) return [];
|
||||
const digits = term.replace(/\D/g, '');
|
||||
const phoneTerms = digits.length >= 6 ? [digits, digits.startsWith('0') ? '27' + digits.slice(1) : null, digits.startsWith('27') ? '0' + digits.slice(2) : null].filter(Boolean) : [];
|
||||
const ci = { contains: term, mode: 'insensitive' };
|
||||
const rows = await db.family.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: ci },
|
||||
{ children: { some: { OR: [{ firstName: ci }, { lastName: ci }] } } },
|
||||
{ members: { some: { user: { OR: [{ name: ci }, { email: ci }, ...phoneTerms.map(p => ({ phoneNumber: { contains: p } }))] } } } },
|
||||
...phoneTerms.map(p => ({ adults: { some: { phone: { contains: p } } } })),
|
||||
],
|
||||
},
|
||||
include: FAMILY_INCLUDE,
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadKidsEvent(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, include: { sessions: { orderBy: { startsAt: 'asc' } } } });
|
||||
if (!event) throw kidsError('Event not found', 404);
|
||||
if (!event.kidsCheckIn || !event.kidsOptionId || !event.kidsRoomSetId) throw kidsError('Kids check-in isn\'t set up for this event.', 400, 'KIDS_NOT_SET_UP');
|
||||
return event;
|
||||
}
|
||||
|
||||
async function roomsFor(event, db = prisma) {
|
||||
return db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, orderBy: { order: 'asc' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's kids settings, with what the supervisor can choose from: main-ticket options
|
||||
* that cover the whole event, and allocation sets.
|
||||
*/
|
||||
async function getSettings(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: String(eventId || '') },
|
||||
select: { id: true, title: true, scheduleType: true, kidsCheckIn: true, kidsOptionId: true, kidsRoomSetId: true },
|
||||
});
|
||||
if (!event) throw kidsError('Event not found', 404);
|
||||
const [options, sets] = await Promise.all([
|
||||
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
|
||||
db.allocationSet.findMany({ where: { eventId: event.id }, select: { id: true, name: true, groups: { orderBy: { order: 'asc' }, select: { id: true, name: true, capacity: true, minAge: true, maxAge: true, ratio: true, order: true } } }, orderBy: { order: 'asc' } }),
|
||||
]);
|
||||
return { event, options, sets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an event's kids settings. `optionId: 'new'` adds a free "Kids check-in" main ticket;
|
||||
* `roomSetId: 'new'` adds a "Kids rooms" set. Switching on needs both.
|
||||
*/
|
||||
async function saveSettings(eventId, { enabled, optionId, roomSetId }, db = prisma) {
|
||||
const { event } = await getSettings(eventId, db);
|
||||
const data = {};
|
||||
if (optionId !== undefined) {
|
||||
if (optionId === 'new') {
|
||||
const opt = await db.eventOption.create({ data: { eventId: event.id, name: 'Kids check-in', price: 0, isMainTicket: true, sessionMode: 'all' } });
|
||||
data.kidsOptionId = opt.id;
|
||||
} else if (optionId) {
|
||||
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
|
||||
if (!opt) throw kidsError('Choose a main ticket of this event that covers the whole event.');
|
||||
data.kidsOptionId = opt.id;
|
||||
} else data.kidsOptionId = null;
|
||||
}
|
||||
if (roomSetId !== undefined) {
|
||||
if (roomSetId === 'new') {
|
||||
const order = await db.allocationSet.count({ where: { eventId: event.id } });
|
||||
const set = await db.allocationSet.create({ data: { eventId: event.id, name: 'Kids rooms', order } });
|
||||
data.kidsRoomSetId = set.id;
|
||||
} else if (roomSetId) {
|
||||
const set = await db.allocationSet.findFirst({ where: { id: String(roomSetId), eventId: event.id } });
|
||||
if (!set) throw kidsError('Choose one of this event\'s allocation sets.');
|
||||
data.kidsRoomSetId = set.id;
|
||||
} else data.kidsRoomSetId = null;
|
||||
}
|
||||
if (enabled !== undefined) data.kidsCheckIn = !!enabled;
|
||||
const next = { ...event, ...data };
|
||||
if (next.kidsCheckIn && (!next.kidsOptionId || !next.kidsRoomSetId)) throw kidsError('Choose the kids ticket and the rooms before switching kids check-in on.');
|
||||
await db.event.update({ where: { id: event.id }, data });
|
||||
return getSettings(event.id, db);
|
||||
}
|
||||
|
||||
// ─── Codes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const isUniqueClash = e => e?.code === 'P2002';
|
||||
|
||||
/** The family's pickup code for the occasion, made if it has none yet. */
|
||||
async function codeForFamily(eventId, occasion, familyId, db = prisma) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const existing = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId, occasion, familyId } } });
|
||||
if (existing) return existing;
|
||||
try {
|
||||
return await db.kidsPickupCode.create({ data: { eventId, occasion, familyId, code: randomCode() } });
|
||||
} catch (e) {
|
||||
if (!isUniqueClash(e)) throw e;
|
||||
// Either the code is taken (try another) or the family got one meanwhile (found above).
|
||||
}
|
||||
}
|
||||
throw kidsError('Could not make a pickup code — try again.', 503);
|
||||
}
|
||||
|
||||
/** Codes held for an offline desk: tops its unused ones up to `count`. */
|
||||
async function reserveCodes(eventId, occasion, deviceId, count = OFFLINE_CODES, db = prisma) {
|
||||
const have = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true } });
|
||||
let need = Math.max(0, count - have.length);
|
||||
for (let tries = 0; need > 0 && tries < count * 5; tries++) {
|
||||
try {
|
||||
await db.kidsPickupCode.create({ data: { eventId, occasion, deviceId, code: randomCode() } });
|
||||
need--;
|
||||
} catch (e) {
|
||||
if (!isUniqueClash(e)) throw e;
|
||||
}
|
||||
}
|
||||
const rows = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true }, orderBy: { createdAt: 'asc' } });
|
||||
return rows.map(r => r.code);
|
||||
}
|
||||
|
||||
// ─── Attendance ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The child's ticket for the event: the one from an earlier check-in, or a new free place on
|
||||
* the kids option — on the parent's kids registration for this event (made the first time,
|
||||
* separate from any registration they paid for themselves).
|
||||
*/
|
||||
async function ticketForChild(tx, event, parentUserId, child) {
|
||||
const earlier = await tx.kidsCheckIn.findFirst({ where: { eventId: event.id, childId: child.id, ticketId: { not: null } }, select: { ticketId: true, attendeeId: true } });
|
||||
if (earlier) {
|
||||
const ticket = await tx.ticket.findUnique({ where: { id: earlier.ticketId }, select: { id: true } });
|
||||
if (ticket) return { ticketId: ticket.id, attendeeId: earlier.attendeeId };
|
||||
}
|
||||
let registration = await tx.registration.findFirst({
|
||||
where: { userId: parentUserId, eventId: event.id, status: { not: 'cancelled' }, registrationOptions: { some: { eventOptionId: event.kidsOptionId, sessionId: null } } },
|
||||
include: { registrationOptions: { where: { eventOptionId: event.kidsOptionId, sessionId: null } } },
|
||||
});
|
||||
if (!registration) {
|
||||
registration = await tx.registration.create({ data: { userId: parentUserId, eventId: event.id, status: 'paid' }, include: { registrationOptions: true } });
|
||||
}
|
||||
let line = registration.registrationOptions[0];
|
||||
if (line) line = await tx.registrationOption.update({ where: { id: line.id }, data: { quantity: { increment: 1 } } });
|
||||
else line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.kidsOptionId, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
|
||||
const order = await tx.attendee.count({ where: { registrationId: registration.id } });
|
||||
const parent = await tx.user.findUnique({ where: { id: parentUserId }, select: { name: true, email: true, phoneNumber: true } });
|
||||
const attendee = await tx.attendee.create({
|
||||
data: {
|
||||
registrationId: registration.id, registrationOptionId: line.id, eventOptionId: event.kidsOptionId,
|
||||
name: childName(child), dateOfBirth: child.dateOfBirth, gender: child.gender,
|
||||
parentName: parent?.name || null, parentEmail: realEmail(parent?.email), parentPhone: parent?.phoneNumber || null, order,
|
||||
},
|
||||
});
|
||||
const ticket = await tx.ticket.create({
|
||||
data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId: parentUserId, eventId: event.id, quantity: 1, attendeeId: attendee.id, emailSent: true },
|
||||
});
|
||||
return { ticketId: ticket.id, attendeeId: attendee.id };
|
||||
}
|
||||
|
||||
// ─── Check-in and check-out ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check children of a family in. `children` = [{ childId, roomId? }] (room suggested from the
|
||||
* age when not given). Children already in for this occasion stay as they are (their room can
|
||||
* change); children checked out earlier today are checked in again. Offline syncs pass the
|
||||
* code row, `clientId`s and the time it happened.
|
||||
* Returns { code, family, checkIns, already }.
|
||||
*/
|
||||
async function checkIn({ eventId, sessionId = null, familyId, children, staffUserId, now = new Date(), occasionOverride = null, codeRow = null, offline = false }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion, sessionId: sid } = occasionOverride || occasionFor(event, event.sessions, sessionId, now);
|
||||
const family = await loadFamily(familyId, db);
|
||||
const parent = family.members[0];
|
||||
if (!parent) throw kidsError('This family has no parent account — add a parent first.');
|
||||
if (!Array.isArray(children) || !children.length) throw kidsError('Choose the children to check in.');
|
||||
const rooms = await roomsFor(event, db);
|
||||
if (!rooms.length) throw kidsError('This event has no kids rooms yet — a supervisor adds them under Kids check-in → Setup.');
|
||||
const counts = await roomCounts(event.id, occasion, db);
|
||||
|
||||
const picks = children.map(p => {
|
||||
const child = family.children.find(c => c.id === p?.childId);
|
||||
if (!child) throw kidsError('That child isn\'t in this family.', 404);
|
||||
let room = p.roomId ? rooms.find(r => r.id === p.roomId) : null;
|
||||
if (p.roomId && !room) throw kidsError('That room isn\'t one of this event\'s kids rooms.');
|
||||
if (!room) room = suggestRoom(rooms, ageOn(child.dateOfBirth, now), counts) || rooms[0];
|
||||
counts[room.id] = (counts[room.id] || 0) + 1;
|
||||
return { child, room, clientId: p.clientId || null };
|
||||
});
|
||||
|
||||
const code = codeRow || await codeForFamily(event.id, occasion, family.id, db);
|
||||
const at = new Date(now);
|
||||
const result = [];
|
||||
const already = [];
|
||||
await db.$transaction(async tx => {
|
||||
for (const { child, room, clientId } of picks) {
|
||||
const existing = await tx.kidsCheckIn.findUnique({ where: { eventId_occasion_childId: { eventId: event.id, occasion, childId: child.id } } });
|
||||
if (existing && !existing.checkedOutAt) {
|
||||
if (existing.roomId !== room.id && !offline) await tx.kidsCheckIn.update({ where: { id: existing.id }, data: { roomId: room.id } });
|
||||
already.push(existing.id);
|
||||
result.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
await tx.kidsCheckIn.update({
|
||||
where: { id: existing.id },
|
||||
data: { roomId: room.id, pickupCodeId: code.id, checkedInAt: at, checkedInById: staffUserId, checkedOutAt: null, checkedOutById: null, collectedBy: null, overrideReason: null },
|
||||
});
|
||||
result.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
const { ticketId, attendeeId } = await ticketForChild(tx, event, parent.userId, child);
|
||||
await tx.ticketUsage.create({ data: { ticketId, scannedById: staffUserId, scannedAt: at, sessionId: sid, offline, syncedAt: offline ? new Date() : null } });
|
||||
if (event.scheduleType !== 'sessions') await tx.ticket.update({ where: { id: ticketId }, data: { isUsed: true } });
|
||||
const row = await tx.kidsCheckIn.create({
|
||||
data: {
|
||||
eventId: event.id, occasion, sessionId: sid, childId: child.id, childName: childName(child), roomId: room.id,
|
||||
pickupCodeId: code.id, attendeeId, ticketId, checkedInAt: at, checkedInById: staffUserId, clientId, offline,
|
||||
},
|
||||
});
|
||||
result.push(row.id);
|
||||
}
|
||||
await tx.child.updateMany({ where: { id: { in: picks.map(p => p.child.id) } }, data: { lastCheckInAt: at } });
|
||||
});
|
||||
|
||||
const checkIns = await db.kidsCheckIn.findMany({ where: { id: { in: result } }, include: { room: { select: { id: true, name: true } }, child: true } });
|
||||
return { event, occasion, sessionId: sid, code: code.code, family, checkIns, already };
|
||||
}
|
||||
|
||||
/** Children in per room for an occasion: { roomId: n }. */
|
||||
async function roomCounts(eventId, occasion, db = prisma) {
|
||||
const rows = await db.kidsCheckIn.groupBy({ by: ['roomId'], where: { eventId, occasion, checkedOutAt: null }, _count: { _all: true } });
|
||||
return Object.fromEntries(rows.filter(r => r.roomId).map(r => [r.roomId, r._count._all]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out with a pickup code. `checkInIds` limits it to some of the children on the code
|
||||
* (default all still in). A code that matches nothing raises a wrong-code alert for the room
|
||||
* and throws WRONG_CODE.
|
||||
*/
|
||||
async function checkOut({ eventId, sessionId = null, code: rawCode, checkInIds = null, collectedBy, roomId = null, staffUserId, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const code = normaliseCode(rawCode);
|
||||
const row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
|
||||
const open = row ? await db.kidsCheckIn.findMany({ where: { pickupCodeId: row.id, checkedOutAt: null, ...(checkInIds?.length ? { id: { in: checkInIds.map(String) } } : {}) } }) : [];
|
||||
if (!open.length) {
|
||||
const typed = String(rawCode || '').trim().slice(0, 20);
|
||||
const message = row ? `Code ${code} has no children still checked in.` : `Wrong pickup code entered: "${typed}".`;
|
||||
if (!row) await db.kidsAlert.create({ data: { eventId: event.id, occasion, kind: 'wrong_code', roomId: roomId || null, message, createdById: staffUserId } });
|
||||
throw kidsError(row ? message : 'That pickup code doesn\'t match any child checked in. The room leader has been alerted.', 404, row ? 'NOTHING_TO_COLLECT' : 'WRONG_CODE');
|
||||
}
|
||||
const who = String(collectedBy || '').trim().slice(0, 100) || null;
|
||||
await db.kidsCheckIn.updateMany({ where: { id: { in: open.map(c => c.id) } }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who } });
|
||||
return { event, occasion, checkIns: await db.kidsCheckIn.findMany({ where: { id: { in: open.map(c => c.id) } }, include: { room: { select: { id: true, name: true } } } }) };
|
||||
}
|
||||
|
||||
/** Check one child out without the code (lost phone, etc.). The reason is required and logged. */
|
||||
async function overrideCheckOut({ checkInId, reason, collectedBy, staffUserId, now = new Date() }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
|
||||
const why = String(reason || '').trim().slice(0, 300);
|
||||
if (why.length < 5) throw kidsError('Give the reason for checking out without the code.');
|
||||
const who = String(collectedBy || '').trim().slice(0, 100);
|
||||
if (!who) throw kidsError('Enter who collected the child.');
|
||||
const updated = await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who, overrideReason: why } });
|
||||
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'override', roomId: ci.roomId, checkInId: ci.id, message: `${ci.childName} checked out without the code by ${who}: ${why}`, createdById: staffUserId, resolvedAt: new Date(now) } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function listNames(names) {
|
||||
return names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** The check-in message: who, where and the code. Never medical or custody details. */
|
||||
// `forParent` adds where to find the QR code (authorised adults have no account to look in).
|
||||
function checkInMessage({ eventTitle, code, children, forParent = true }) {
|
||||
const names = children.map(c => c.name);
|
||||
const rooms = [...new Set(children.map(c => c.room).filter(Boolean))];
|
||||
const how = forParent ? `Show this code — or the QR code in My account → Kids (${frontendUrl()}/dashboard/user/kids) —` : 'Show this code';
|
||||
return `${listNames(names)} ${names.length === 1 ? 'is' : 'are'} checked in at ${eventTitle}${rooms.length ? ` (${rooms.join(', ')})` : ''}. Pickup code: ${code}. ${how} to collect ${names.length === 1 ? 'them' : 'them all'}.`;
|
||||
}
|
||||
|
||||
/** Send a text to a parent the way they like it (WhatsApp, email, or whichever works). */
|
||||
async function messageParent(user, { subject, heading, text }) {
|
||||
const { canWhatsApp, shouldEmail, waTextAny } = require('./notify');
|
||||
const email = realEmail(user.email);
|
||||
const sent = [];
|
||||
if (canWhatsApp(user) || (!email && user.phoneNumber)) {
|
||||
await waTextAny(user, text);
|
||||
sent.push('whatsapp');
|
||||
}
|
||||
if (email && (shouldEmail(user) || !sent.length)) {
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({ to: email, subject, ...buildPrivacyEmail({ name: user.name, heading, paragraphs: [text] }) }).catch(() => {});
|
||||
sent.push('email');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Tell the family's parents (and an authorised adult, if chosen) the code. Fire-and-forget. */
|
||||
async function sendCheckInMessages({ event, family, code, checkIns, adultId = null }) {
|
||||
const children = checkIns.map(ci => ({ name: ci.child?.firstName || ci.childName, room: ci.room?.name || null }));
|
||||
const text = checkInMessage({ eventTitle: event.title, code, children });
|
||||
for (const m of family.members) {
|
||||
await messageParent(m.user, { subject: `Pickup code ${code} — ${event.title}`, heading: `Pickup code ${code}`, text }).catch(() => {});
|
||||
}
|
||||
const adult = adultId ? family.adults.find(a => a.id === adultId) : null;
|
||||
if (adult?.phone) {
|
||||
const { sendText } = require('./whatsapp');
|
||||
await sendText(adult.phone, `${family.name}: ${checkInMessage({ eventTitle: event.title, code, children, forParent: false })}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Page a parent: "Please come to the Toddlers room". Logged as an alert the parent sees too. */
|
||||
async function pageParent({ checkInId, message, staffUserId }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { room: true, event: { select: { title: true } }, pickupCode: { select: { familyId: true } } } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
|
||||
const family = ci.pickupCode.familyId ? await loadFamily(ci.pickupCode.familyId, db) : null;
|
||||
if (!family?.members.length) throw kidsError('There\'s no parent to contact for this child.');
|
||||
const extra = String(message || '').trim().slice(0, 300);
|
||||
const first = ci.childName.split(' ')[0];
|
||||
const text = `Please come to the ${ci.room?.name || 'kids'} room for ${first} (${ci.event.title}).${extra ? ` ${extra}` : ''}`;
|
||||
const channels = new Set();
|
||||
for (const m of family.members) {
|
||||
for (const c of await messageParent(m.user, { subject: `Please come to the ${ci.room?.name || 'kids'} room`, heading: `Please come to the ${ci.room?.name || 'kids'} room`, text }).catch(() => [])) channels.add(c);
|
||||
}
|
||||
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'page_parent', roomId: ci.roomId, checkInId: ci.id, message: text, createdById: staffUserId } });
|
||||
return { sent: [...channels], text };
|
||||
}
|
||||
|
||||
// ─── Boards ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything the desk and room screens show for an occasion: rooms with counts and leaders on
|
||||
* duty, the children checked in (with allergies, medical and custody notes), open alerts.
|
||||
*/
|
||||
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, rostered] = await Promise.all([
|
||||
roomsFor(event, db),
|
||||
db.kidsCheckIn.findMany({
|
||||
where: { eventId: event.id, occasion },
|
||||
include: {
|
||||
child: true,
|
||||
pickupCode: { select: { code: true, familyId: true } },
|
||||
checkedInBy: { select: { name: true } },
|
||||
checkedOutBy: { select: { name: true } },
|
||||
},
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
}),
|
||||
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 }) : [];
|
||||
const famById = Object.fromEntries(families.map(f => [f.id, f]));
|
||||
|
||||
const inByRoom = {};
|
||||
for (const c of checkIns) if (!c.checkedOutAt && c.roomId) inByRoom[c.roomId] = (inByRoom[c.roomId] || 0) + 1;
|
||||
const sessionLabel = sid ? sessionsUtil.sessionLabel(event.sessions.find(s => s.id === sid), event.sessions) : null;
|
||||
|
||||
return {
|
||||
event: { id: event.id, title: event.title, scheduleType: event.scheduleType },
|
||||
occasion,
|
||||
sessionId: sid,
|
||||
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), rostered: rostered[r.id] || [], ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
}),
|
||||
checkIns: checkIns.map(c => {
|
||||
const fam = famById[c.pickupCode.familyId];
|
||||
const child = c.child ? childView(c.child, { on: now }) : null;
|
||||
return {
|
||||
id: c.id,
|
||||
childId: c.childId,
|
||||
name: c.childName,
|
||||
firstName: child?.firstName || c.childName.split(' ')[0],
|
||||
age: child?.age ?? null,
|
||||
roomId: c.roomId,
|
||||
code: c.pickupCode.code,
|
||||
checkedInAt: c.checkedInAt,
|
||||
checkedInBy: c.checkedInBy?.name || null,
|
||||
checkedOutAt: c.checkedOutAt,
|
||||
checkedOutBy: c.checkedOutBy?.name || null,
|
||||
collectedBy: c.collectedBy,
|
||||
override: !!c.overrideReason,
|
||||
offline: c.offline,
|
||||
allergies: child?.allergies || '',
|
||||
medicalNotes: child?.medicalNotes || '',
|
||||
custodyNotes: child?.custodyNotes || '',
|
||||
sticker: stickerAlert(child?.allergies, child?.medicalNotes),
|
||||
family: fam ? { id: fam.id, name: fam.name, parents: familyView(fam, { notes: false }).parents, adults: familyView(fam, { notes: false }).adults } : null,
|
||||
};
|
||||
}),
|
||||
alerts: alerts.map(a => ({ id: a.id, kind: a.kind, roomId: a.roomId, checkInId: a.checkInId, message: a.message, by: a.createdBy?.name || null, at: a.createdAt, resolvedAt: a.resolvedAt })),
|
||||
};
|
||||
}
|
||||
|
||||
/** "I'm on duty" / "I'm off" in a room for the occasion. */
|
||||
async function setDuty({ eventId, sessionId = null, roomId, userId, on, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: event.kidsRoomSetId } });
|
||||
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
|
||||
// One room at a time: going on duty somewhere ends duty elsewhere for this occasion.
|
||||
await db.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null, ...(on ? {} : { roomId: room.id }) }, data: { endedAt: new Date(now) } });
|
||||
if (on) await db.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId } });
|
||||
return { roomId: room.id, on: !!on };
|
||||
}
|
||||
|
||||
/** Move a child to another room. */
|
||||
async function moveRoom({ checkInId, roomId }, db = prisma) {
|
||||
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { event: { select: { kidsRoomSetId: true } } } });
|
||||
if (!ci) throw kidsError('Check-in not found', 404);
|
||||
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: ci.event.kidsRoomSetId } });
|
||||
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
|
||||
return db.kidsCheckIn.update({ where: { id: ci.id }, data: { roomId: room.id } });
|
||||
}
|
||||
|
||||
// ─── The parent's view ────────────────────────────────────────────────────────
|
||||
|
||||
/** The parent's live codes: children checked in now (not collected), grouped by code, plus pages from today. */
|
||||
async function activeForFamily(familyId, now = new Date(), db = prisma) {
|
||||
const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const checkIns = await db.kidsCheckIn.findMany({
|
||||
where: { pickupCode: { familyId }, checkedOutAt: null, checkedInAt: { gte: since } },
|
||||
include: { pickupCode: { select: { code: true } }, room: { select: { name: true } }, event: { select: { id: true, title: true } } },
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
});
|
||||
const byCode = new Map();
|
||||
for (const c of checkIns) {
|
||||
const key = `${c.eventId}:${c.pickupCode.code}`;
|
||||
if (!byCode.has(key)) byCode.set(key, { code: c.pickupCode.code, qr: QR_PREFIX + c.pickupCode.code, event: c.event, children: [] });
|
||||
byCode.get(key).children.push({ name: c.childName, room: c.room?.name || null, checkedInAt: c.checkedInAt });
|
||||
}
|
||||
const pages = checkIns.length ? await db.kidsAlert.findMany({
|
||||
where: { kind: 'page_parent', checkInId: { in: checkIns.map(c => c.id) }, createdAt: { gte: since } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, message: true, createdAt: true },
|
||||
}) : [];
|
||||
return { codes: [...byCode.values()], pages };
|
||||
}
|
||||
|
||||
// ─── Offline desks ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What a desk downloads to keep checking in without a connection: the families (with
|
||||
* children), the rooms, who is already in, and codes reserved for this device.
|
||||
*/
|
||||
async function offlineSnapshot({ eventId, sessionId = null, deviceId, now = new Date() }, db = prisma) {
|
||||
const dev = String(deviceId || '').trim().slice(0, 64);
|
||||
if (!dev) throw kidsError('deviceId is required');
|
||||
const b = await board({ eventId, sessionId, now }, db);
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const families = await db.family.findMany({ where: { children: { some: {} }, members: { some: {} } }, include: FAMILY_INCLUDE, orderBy: { name: 'asc' }, take: 3000 });
|
||||
const codes = await reserveCodes(event.id, b.occasion, dev, OFFLINE_CODES, db);
|
||||
return {
|
||||
occasion: b.occasion,
|
||||
sessionId: b.sessionId,
|
||||
sessionLabel: b.sessionLabel,
|
||||
event: b.event,
|
||||
rooms: b.rooms,
|
||||
checkIns: b.checkIns,
|
||||
families: families.map(f => familyView(f, { on: now })),
|
||||
codes,
|
||||
downloadedAt: new Date(now).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an offline desk. `checkIns` = [{ clientId, familyId, childId, roomId, code, at }] made
|
||||
* with the device's reserved codes; `checkOuts` = [{ clientId, collectedBy, at }] for children
|
||||
* checked in on this device. Idempotent (clientId). Returns { checkedIn, checkedOut, conflicts }.
|
||||
*/
|
||||
async function syncOffline({ eventId, deviceId, occasion, sessionId = null, checkIns = [], checkOuts = [], staffUserId }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const dev = String(deviceId || '').trim().slice(0, 64);
|
||||
const occ = String(occasion || '');
|
||||
const validOccasion = event.scheduleType === 'sessions' ? event.sessions.some(s => s.id === occ) : /^\d{4}-\d{2}-\d{2}$/.test(occ);
|
||||
if (!dev || !validOccasion) throw kidsError('This offline list isn\'t for this event.');
|
||||
const sid = event.scheduleType === 'sessions' ? occ : null;
|
||||
const out = { checkedIn: 0, checkedOut: 0, conflicts: [] };
|
||||
|
||||
for (const item of (Array.isArray(checkIns) ? checkIns : []).slice(0, 500)) {
|
||||
const clientId = String(item?.clientId || '').slice(0, 64);
|
||||
if (!clientId) continue;
|
||||
if (await db.kidsCheckIn.findUnique({ where: { clientId } })) continue;
|
||||
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
|
||||
try {
|
||||
const code = normaliseCode(item.code);
|
||||
let row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion: occ, code } } }) : null;
|
||||
if (!row || (row.deviceId !== dev && row.familyId !== item.familyId)) throw kidsError(`Code ${item.code} wasn't reserved for this device.`);
|
||||
if (!row.familyId) {
|
||||
const theirs = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId: event.id, occasion: occ, familyId: String(item.familyId || '') } } });
|
||||
if (theirs) {
|
||||
out.conflicts.push({ clientId, message: `This family already had code ${theirs.code} from another desk — tell the parent their code is ${theirs.code}, not ${code}.` });
|
||||
row = theirs;
|
||||
} else {
|
||||
row = await db.kidsPickupCode.update({ where: { id: row.id }, data: { familyId: String(item.familyId || '') } });
|
||||
}
|
||||
}
|
||||
await checkIn({
|
||||
eventId: event.id, familyId: item.familyId, children: [{ childId: item.childId, roomId: item.roomId, clientId }],
|
||||
staffUserId, now: at, occasionOverride: { occasion: occ, sessionId: sid }, codeRow: row, offline: true,
|
||||
}, db);
|
||||
out.checkedIn++;
|
||||
} catch (e) {
|
||||
out.conflicts.push({ clientId, message: e.message || 'Could not sync this check-in.' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of (Array.isArray(checkOuts) ? checkOuts : []).slice(0, 500)) {
|
||||
const ci = item?.clientId ? await db.kidsCheckIn.findUnique({ where: { clientId: String(item.clientId) } }) : null;
|
||||
if (!ci) { out.conflicts.push({ clientId: item?.clientId || null, message: 'A check-out was for a check-in that never synced.' }); continue; }
|
||||
if (ci.checkedOutAt) continue;
|
||||
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
|
||||
await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: at, checkedOutById: staffUserId, collectedBy: String(item.collectedBy || '').trim().slice(0, 100) || null } });
|
||||
out.checkedOut++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Privacy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** For anonymiseUser: leave the family; a family nobody is left in is deleted with its children. */
|
||||
async function removeFromFamily(tx, userId) {
|
||||
const m = await tx.familyMember.findUnique({ where: { userId } });
|
||||
if (!m) return { familyDeleted: false };
|
||||
await tx.familyMember.delete({ where: { id: m.id } });
|
||||
const left = await tx.familyMember.count({ where: { familyId: m.familyId } });
|
||||
if (left) return { familyDeleted: false };
|
||||
await deleteFamilyData(tx, m.familyId);
|
||||
return { familyDeleted: true };
|
||||
}
|
||||
|
||||
/** Delete a family and its children; check-ins keep the attendance but lose who collected. */
|
||||
async function deleteFamilyData(tx, familyId) {
|
||||
await tx.kidsCheckIn.updateMany({ where: { pickupCode: { familyId } }, data: { collectedBy: null, overrideReason: null } });
|
||||
await tx.family.delete({ where: { id: familyId } });
|
||||
}
|
||||
|
||||
/** For "Download my data". */
|
||||
async function exportFamily(userId, db = prisma) {
|
||||
const f = await familyOfUser(userId, db);
|
||||
if (!f) return null;
|
||||
const checkIns = await db.kidsCheckIn.findMany({
|
||||
where: { pickupCode: { familyId: f.id } },
|
||||
include: { event: { select: { title: true } }, room: { select: { name: true } } },
|
||||
orderBy: { checkedInAt: 'asc' },
|
||||
});
|
||||
const v = familyView(f);
|
||||
return {
|
||||
name: v.name,
|
||||
parents: v.parents.map(p => p.name),
|
||||
children: v.children.map(c => ({ name: c.name, dateOfBirth: c.dateOfBirth, gender: c.gender, allergies: c.allergies, medicalNotes: c.medicalNotes, custodyNotes: c.custodyNotes })),
|
||||
authorisedAdults: v.adults,
|
||||
checkIns: checkIns.map(c => ({ child: c.childName, event: c.event?.title, room: c.room?.name || null, in: c.checkedInAt, out: c.checkedOutAt, collectedBy: c.collectedBy })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: children not checked in (or added) for `months` are deleted, then families left
|
||||
* with no children. Who collected children (and override reasons) is cleared from check-ins as
|
||||
* old; alerts that old are deleted.
|
||||
*/
|
||||
async function runKidsRetention(cutoff, db = prisma) {
|
||||
const children = await db.child.deleteMany({
|
||||
where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] },
|
||||
});
|
||||
const emptyFamilies = await db.family.findMany({ where: { children: { none: {} }, updatedAt: { lt: cutoff } }, select: { id: true } });
|
||||
for (const f of emptyFamilies) await db.$transaction(tx => deleteFamilyData(tx, f.id));
|
||||
const scrubbed = await db.kidsCheckIn.updateMany({ where: { checkedInAt: { lt: cutoff }, OR: [{ collectedBy: { not: null } }, { overrideReason: { not: null } }] }, data: { collectedBy: null, overrideReason: null } });
|
||||
const alerts = await db.kidsAlert.deleteMany({ where: { createdAt: { lt: cutoff } } });
|
||||
const duties = await db.kidsRoomDuty.deleteMany({ where: { startedAt: { lt: cutoff } } });
|
||||
return { children: children.count, families: emptyFamilies.length, checkIns: scrubbed.count, alerts: alerts.count + duties.count };
|
||||
}
|
||||
|
||||
async function previewKidsRetention(cutoff, db = prisma) {
|
||||
return {
|
||||
count: await db.child.count({ where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] } }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODE_ALPHABET, CODE_LENGTH, QR_PREFIX, kidsError,
|
||||
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, messageParent, sendCheckInMessages, pageParent,
|
||||
board, setDuty, moveRoom, activeForFamily, offlineSnapshot, syncOffline,
|
||||
removeFromFamily, exportFamily, runKidsRetention, previewKidsRetention,
|
||||
};
|
||||
@@ -1422,7 +1422,13 @@ async function sendReceiptToUser(paymentId, requesterId, channel) {
|
||||
async function sendDailyEventSummaries(now = new Date()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||
// Campus summary recipients (Site settings → Campuses) also get their campus's events.
|
||||
const campusesOn = await require('./campuses').campusesEnabled();
|
||||
const notifyInclude = {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
...(campusesOn ? { campus: { select: { notifyRecipients: { select: { email: true } } } } } : {}),
|
||||
};
|
||||
let events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
|
||||
include: notifyInclude,
|
||||
@@ -1462,6 +1468,7 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
: 0;
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
|
||||
const to = buildEventNotifyRecipientList(ev);
|
||||
for (const u of ev.campus?.notifyRecipients || []) if (u.email && !to.includes(u.email)) to.push(u.email);
|
||||
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
|
||||
}));
|
||||
} catch (e) {
|
||||
@@ -1506,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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user