Compare commits

..
20 Commits
Author SHA1 Message Date
joshua 7ae0b6d23b Merge branch 'feat/event-clone' 2026-10-28 10:01:18 +02:00
joshuaandClaude Opus 5.5 6ef19d3290 Copy an event (1.47.0)
"Copy" on each event card opens the event editor filled in from that
event (campus, sessions, options, variants, early birds, sections, form,
recipients, feedback questions, certificates, reminders). "New start"
moves every date with it. Allocation groups and, for admins, planned
costs are copied by POST /api/events/:id/copy-from. Registrations,
coupons, attachments, kids check-in and volunteer roles are not copied.

Roadmap: event series and cloning ships as 1.47.0-1.47.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:17 +02:00
joshua 51355069da Merge branch 'chore/whats-new-history' 2026-10-28 09:25:08 +02:00
joshuaandClaude Opus 5.5 3a96f9c1dc WHATS-NEW.md: church-facing notes for every version back to 1.0.0
The Hub reads this file from main (Hub 1.4.1), so these show on sites' Licence
page without a new release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:25:08 +02:00
joshua a7261db191 Merge branch 'chore/licence-updates' 2026-10-28 09:13:30 +02:00
joshuaandClaude Opus 5.5 6045d1b05e WHATS-NEW.md for churches; docs, changelog (1.46.2)
Client-facing release notes the Hub reads from git with the version tags, so
the latest version and its notes reach installs without touching the Hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:13:30 +02:00
joshuaandClaude Opus 5.5 5df04f5a4a Licence page: what's new, withdraw request, Not now, provider notices
- Site settings -> Licence shows the release notes for every newer version from
  the Hub; urgent (security/serious) updates are marked and can't be put off.
- Withdraw an update request (the provider is told through the Hub).
- "Not now" on the banner or Licence page hides update reminders until the
  next version.
- Notices from the provider show as a dashboard banner for site admins and
  supervisors, in their time window, and on the Licence page.
- New audit actions (migration), API docs, help, protocol doc, tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:54:35 +02:00
joshua 35805f0c79 Merge branch 'chore/remove-ci' 2026-10-28 08:15:45 +02:00
joshuaandClaude Opus 5.5 174022296b Remove the Gitea CI workflow; docs, changelog (1.46.1)
There's no Actions runner (no machine that can run Docker), 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:15:44 +02:00
joshua bc22a94f92 Merge branch 'feat/volunteer-rosters'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-28 03:59:52 +02:00
joshuaandClaude Opus 5.5 bbde6791cb Volunteer rosters: docs, changelog (1.46.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:59:52 +02:00
joshuaandClaude Opus 5.5 e9a2ce9d01 Volunteer rosters: serving, roster and check-in pages, help, e2e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:59:52 +02:00
joshuaandClaude Opus 5.5 39885a8719 Volunteer rosters: roles, sign-ups, swaps, QR check-in, kids-room duty (backend)
Serving roles per event or session, sign-ups and assigning with an overlap
check, swaps that happen on accept (notify recipients emailed), QR and manual
check-in (kids-room roles start rostered duty), optional free volunteer ticket,
day-before reminder job, hours served, privacy hooks. The kids check-in and
volunteer tickets are left out of public event pages and public registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:59:52 +02:00
joshua 1ce6f23ad8 Merge branch 'fix/legal-pages'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-28 01:09:51 +02:00
joshuaandClaude Opus 5.5 1dbc37ba08 Privacy notice and terms of use brought up to date (1.45.1)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMccH2LkkfYmsSLwPaq
2026-10-28 01:09:51 +02:00
joshua 42a369278e Merge branch 'feat/kids-check-in'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-28 01:01:09 +02:00
joshuaandClaude Opus 5.5 e249d43c79 Kids check-in: docs, changelog (1.45.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:08 +02:00
joshuaandClaude Opus 5.5 00b465b97d Kids check-in: desk, room screen, setup and parent pages, help, e2e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:08 +02:00
joshuaandClaude Opus 5.5 794396ca9c Kids check-in: families, pickup codes, rooms, check-in/out, offline sync (backend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:46:41 +02:00
joshuaandClaude Opus 5.5 09ef2c1df1 Roadmap: kids check-in fully digital, wallet passes removed from scope
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:29:10 +02:00
91 changed files with 8192 additions and 360 deletions
-129
View File
@@ -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'"
+138
View File
@@ -7,6 +7,144 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
-18
View File
@@ -310,24 +310,6 @@ and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
### Deploying from CI over SSH
The Gitea workflow deploys on a `v*` tag by SSHing to `DEPLOY_HOST`. Behind frp, that needs an
SSH tunnel too, e.g. in `frpc.ini`:
```ini
[hope-events-ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 6022
```
Then use the VPS as `DEPLOY_HOST`. The workflow's `ssh` call (`.gitea/workflows/ci.yml`) doesn't
take a port yet, so add `-p 6022` there (or a `DEPLOY_PORT` secret) before relying on it. Only
open this if you need it: it exposes this server's SSH on the VPS. Use key-only login. Deploying
by hand (above) needs none of this.
## Troubleshooting
| Symptom | Likely cause |
+112 -16
View File
@@ -30,12 +30,14 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Multi-campus](#multi-campus)
17. [Accounts & security](#accounts--security)
18. [Privacy (POPIA)](#privacy-popia)
19. [Help system](#help-system)
20. [Background jobs](#background-jobs)
21. [Operations](#operations)
22. [Page map](#page-map)
17. [Kids check-in](#kids-check-in)
18. [Volunteer rosters](#volunteer-rosters)
19. [Accounts & security](#accounts--security)
20. [Privacy (POPIA)](#privacy-popia)
21. [Help system](#help-system)
22. [Background jobs](#background-jobs)
23. [Operations](#operations)
24. [Page map](#page-map)
---
@@ -88,6 +90,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
@@ -394,6 +404,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).
---
@@ -554,6 +568,83 @@ default — single-campus churches never see campus fields).
---
## 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
@@ -670,6 +761,7 @@ 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`) |
| `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`) |
@@ -690,8 +782,13 @@ can't start, the same jobs run on in-process timers.
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
which tells the provider through the Hub (the site never updates itself).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
itself). A request can be **withdrawn**. **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
@@ -705,11 +802,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).
---
@@ -734,9 +829,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 |
+3 -3
View File
@@ -92,9 +92,9 @@ Documentation: see Frontend README for environment and scripts.
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
servers and database aren't touched. The database user needs permission to create
databases.
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
- There's no CI: run these yourself, plus `npx eslint .` in `frontend/`, before tagging a
release (the deploy's `next build` fails on any lint error). Releases are deployed by hand
with `scripts/deploy.sh` (see [DEPLOYMENT.md](./DEPLOYMENT.md)).
## Deployment
+2 -1
View File
@@ -114,8 +114,9 @@ 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. |
| `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. |
+481
View File
@@ -0,0 +1,481 @@
# 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.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.
+2
View File
@@ -75,6 +75,8 @@ 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
# 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
+81 -1
View File
@@ -136,6 +136,7 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias |
| POST | `/api/events` | supervisor+ | Create event |
| PUT | `/api/events/:id` | supervisor+ | Update event |
| POST | `/api/events/:id/copy-from` | supervisor+ | Copying an event: copy `fromEventId`'s allocation sets/groups (not kids rooms) and, for admins, planned costs into this new event (409 once it has registrations or groups) |
| DELETE | `/api/events/:id` | admin | Delete event |
| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) |
| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
@@ -170,6 +171,10 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
}
```
A copy made with "Copy" on the Events page is an ordinary `POST /api/events` from the event form,
with `clonedFromId` (the source event, noted in the `event_created` audit entry), followed by the
options, feedback form, sections and recipients as for a new event, then `POST /api/events/:id/copy-from`.
---
## Event Options & Variants
@@ -844,10 +849,13 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable, urgentUpdate, updateSnoozed, releases: [{ version, releasedAt, urgent, notes }], updateRequest, notices }` — `releases` is what's new in each version newer than this one (from the Hub) |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
| POST | `/api/hub/update-request/cancel` | admin | Withdraw the update request; the Hub tells the provider. Returns the status. Audited as `hub_update_request_withdrawn` |
| POST | `/api/hub/update-snooze` | admin | `{ snooze }` — "Not now": `updateSnoozed` is true (no banner) until a newer version than the one put off is out. `400` when there's no update or it's urgent (security/serious fix). `{ snooze: false }` turns reminders back on. Audited as `hub_update_snoozed` / `hub_update_unsnoozed` |
| GET | `/api/hub/notices` | supervisor | The provider's notices showing now: `[{ id, level: info\|maintenance\|warning, title, message, startsAt, endsAt, updatedAt }]`, from the last heartbeat |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
@@ -885,6 +893,78 @@ their campuses' events plus church-wide ones on every staff/supervisor endpoint
- `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`.
## 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 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.44.0",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.44.0",
"version": "1.47.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.44.0",
"version": "1.47.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -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';
+305
View File
@@ -151,6 +151,20 @@ model User {
// 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")
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@index([homeCampusId])
}
@@ -286,6 +300,25 @@ 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
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@ -381,6 +414,7 @@ model Registration {
bundleApplications RegistrationBundle[]
attendees Attendee[]
waitlistEntries WaitlistEntry[]
volunteerSlots VolunteerSlot[]
@@index([userId])
@@index([eventId])
@@ -648,6 +682,9 @@ enum AdminAuditAction {
breach_updated
hub_connected
hub_update_requested
hub_update_request_withdrawn
hub_update_snoozed
hub_update_unsnoozed
campuses_toggled
campus_created
campus_updated
@@ -655,6 +692,16 @@ enum AdminAuditAction {
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
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -1328,11 +1375,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])
@@ -1608,6 +1664,7 @@ model EventSession {
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
waitlistEntries WaitlistEntry[]
servingRoles ServingRole[]
@@index([eventId, startsAt])
}
@@ -1829,3 +1886,251 @@ 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])
}
@@ -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 -2
View File
@@ -8,6 +8,7 @@ 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');
@@ -101,6 +102,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;
@@ -195,7 +210,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),
});
@@ -374,7 +389,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.
@@ -532,6 +548,8 @@ const getEventById = async (req, res) => {
if (!isStaffOrHigher) {
delete event.createdBy;
delete event.notifyRecipients;
// The kids check-in and volunteer tickets are handed out by staff, never sold.
event.eventOptions = publicOptions(event);
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
@@ -2152,6 +2170,7 @@ const getEventByAlias = async (req, res) => {
module.exports = {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
+89 -2
View File
@@ -19,9 +19,47 @@ function pendingUpdateRequest(version, requestedAt, requestedVersion) {
return { requestedAt, version: requestedVersion };
}
const str = (v, max) => String(v ?? '').slice(0, max);
/** What's new in each version newer than this one (from the Hub), newest first. */
function newerReleases(releases, version) {
return (releases || [])
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
.sort((a, b) => compareVersions(b.version, a.version))
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
}
/**
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
* serious bug fixes) can't be put off.
*/
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
}
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
/** The provider's notices showing now: started (or no start) and not ended. */
function activeNotices(notices, now = new Date()) {
return (notices || [])
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
.map(n => ({
id: str(n.id, 64),
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
title: str(n.title, 120),
message: str(n.message, 2000),
startsAt: n.startsAt || null,
endsAt: n.endsAt || null,
updatedAt: n.updatedAt || null,
}));
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
const releases = newerReleases(s.releases, APP_VERSION);
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
return {
state: s.state,
readOnly: s.readOnly,
@@ -45,8 +83,12 @@ async function statusPayload({ fresh = false } = {}) {
lastError: s.lastError,
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
updateAvailable,
urgentUpdate,
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
releases,
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
notices: activeNotices(s.notices),
};
}
@@ -107,4 +149,49 @@ const requestUpdate = async (req, res) => {
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
// @desc Withdraw the update request (the provider is told not to update after all)
// @route POST /api/hub/update-request/cancel
// @access Private/Admin
const cancelUpdateRequest = async (req, res) => {
try {
await hub.cancelUpdateRequest();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
// @route POST /api/hub/update-snooze body: { snooze }
// @access Private/Admin
const snoozeUpdate = async (req, res) => {
try {
const snooze = req.body?.snooze !== false;
const current = await statusPayload({ fresh: true });
if (snooze) {
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
}
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc The provider's notices showing now (banner for site admins and supervisors)
// @route GET /api/hub/notices
// @access Private/Supervisor
const getNotices = async (req, res) => {
try {
const s = await licenceStatus();
res.json(activeNotices(s.notices));
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };
+448
View File
@@ -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,
};
@@ -13,6 +13,7 @@ const { findAccountByContact } = require('../utils/accountMatch');
const { ensureConsentRequests } = require('../utils/consent');
const { takenCount } = require('../utils/stock');
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
const { reservedOptionIds } = require('../utils/volunteers');
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
// the background so a slow mail/WhatsApp send never holds up the registration.
@@ -237,9 +238,11 @@ const createRegistration = async (req, res) => {
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
// The kids check-in and volunteer tickets are handed out by staff, never sold.
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
if (!eventOption || reserved.has(eventOption.id)) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
}
if (!option.quantity || option.quantity < 1) {
@@ -1507,6 +1510,18 @@ const updateRegistrationOptions = async (req, res) => {
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
}
}
// The kids check-in and volunteer tickets are handed out by staff: the owner can keep (or
// lower) the places they were given, not add more.
if (!isStaffRole(req.user.role)) {
for (const id of reservedOptionIds(registration.event)) {
const had = registration.registrationOptions.filter(ro => ro.eventOptionId === id).reduce((n, ro) => n + (ro.quantity || 0), 0);
const asked = options.filter(o => o.eventOptionId === id).reduce((n, o) => n + o.quantity, 0);
if (asked > had) {
res.status(400);
throw new Error('One or more options do not belong to this event');
}
}
}
// Picked-session lines aren't edited here: leave them exactly as they are (they still
// count towards the total due).
@@ -0,0 +1,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,
};
+33 -5
View File
@@ -1,10 +1,12 @@
/**
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
* Install → Hub only. Stored in AppSetting:
* signed heartbeat every 15 minutes, and a signed update request (or withdrawal) when an admin
* asks. Install → Hub only. Stored in AppSetting:
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
* hub_update_requested_at, hub_update_requested_version.
* hub_releases, hub_notices (JSON from the last heartbeat: what's new, provider's notices),
* hub_update_requested_at, hub_update_requested_version,
* hub_update_snoozed_version ("Not now" for that version; set here, not by the Hub).
* These keys are kept out of the generic settings endpoints (settingsController).
*/
@@ -16,7 +18,7 @@ const report = require('./report');
const PROTOCOL_VERSION = 1;
const TIMEOUT_MS = 15000;
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
@@ -38,6 +40,14 @@ async function save(values) {
clearLicenceCache();
}
/** What's new and the provider's notices from a register/heartbeat reply (a Hub before 1.4.0 sends neither). */
function fromHub(data) {
return {
hub_releases: Array.isArray(data.releases) ? JSON.stringify(data.releases.slice(0, 20)) : null,
hub_notices: Array.isArray(data.notices) ? JSON.stringify(data.notices.slice(0, 20)) : null,
};
}
async function read(keys) {
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
return Object.fromEntries(rows.map(r => [r.key, r.value]));
@@ -101,6 +111,7 @@ async function connect(licenceKey, adminContact) {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return claims;
}
@@ -119,6 +130,7 @@ async function heartbeat() {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return { ok: true, status: claims?.status || null };
} catch (e) {
@@ -149,6 +161,22 @@ async function requestUpdate() {
return data;
}
/** Withdraw the update request (§3a): the provider is told not to update after all. */
async function cancelUpdateRequest() {
try {
await signedPost('/api/installs/update-request/cancel', { protocolVersion: PROTOCOL_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub can\'t withdraw update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
await save({ hub_update_requested_at: null, hub_update_requested_version: null });
}
/** "Not now": no update reminders until a newer version than `version` is out. Null turns them back on. */
async function snoozeUpdate(version) {
await save({ hub_update_snoozed_version: version || null });
}
/**
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
@@ -164,4 +192,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
+13 -1
View File
@@ -77,10 +77,19 @@ function checkDisabled() {
let cache = null;
function parseList(json) {
try {
const list = JSON.parse(json || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
async function licenceStatus({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
@@ -95,6 +104,9 @@ async function licenceStatus({ fresh = false } = {}) {
latestVersion: s.hub_latest_version || null,
updateRequestedAt: s.hub_update_requested_at || null,
updateRequestedVersion: s.hub_update_requested_version || null,
updateSnoozedVersion: s.hub_update_snoozed_version || null,
releases: parseList(s.hub_releases),
notices: parseList(s.hub_notices),
};
cache = { at: Date.now(), value };
return value;
+5
View File
@@ -13,6 +13,7 @@ const { licenceStatus } = require('./licence');
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
@@ -26,6 +27,10 @@ const BLOCKED = [
['POST', /^\/api\/waitlist\/?$/],
['POST', /^\/api\/coupons\/?$/],
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
];
function isBlocked(method, path) {
+81
View File
@@ -183,6 +183,8 @@ app.get('/health', async (req, res) => {
// 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/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -582,6 +584,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' }]}]},
@@ -924,6 +930,13 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request/cancel', auth:'admin', desc:'Withdraw the update request (the provider is told not to update after all)',
responses:[{ status:200, desc:'Status, updateRequest null' }, { status:502, desc:'Hub unreachable, or a Hub too old to withdraw requests (NOT_SUPPORTED)' }]},
{ method:'POST', path:'/api/hub/update-snooze', auth:'admin', desc:'"Not now": no update reminders until a newer version is out. { snooze: false } turns them back on',
request:{ body:{ snooze:true } },
responses:[{ status:200, desc:'Status', body:{ updateAvailable:true, updateSnoozed:true } }, { status:400, desc:'No update, or an urgent one (can\'t be put off)' }]},
{ method:'GET', path:'/api/hub/notices', auth:'supervisor', desc:'The provider\'s notices showing now (the dashboard banner for site admins and supervisors)',
responses:[{ status:200, desc:'Success', body:[{ id:'…', level:'maintenance', title:'Planned maintenance', message:'The site will be down for 30 minutes.', startsAt:'2026-11-01T16:00:00.000Z', endsAt:'2026-11-01T16:30:00.000Z' }] }]},
]},
{ title: 'Campuses', base: '/api/campuses', endpoints: [
@@ -942,6 +955,74 @@ app.get('/docs', async (req, res) => {
{ 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: '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:[] }}]},
+4
View File
@@ -45,6 +45,10 @@ 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: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
+5 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
@@ -29,7 +30,7 @@ const {
getEventByAlias
} = require('../controllers/eventController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Public routes
router.get('/', getEvents);
@@ -55,6 +56,9 @@ router.route('/:id')
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
.delete(protect, admin, deleteEvent);
// Clone: copy allocation groups (and costs, for admins) from the source event into a new copy.
router.post('/:id/copy-from', protect, supervisor, scopeEvent({ need: 'manage' }), scopeBody([['fromEventId', 'event']]), copyEventSetupFrom);
// Notification recipients: who gets registration/payment/daily-summary emails for this event
router.route('/:id/notify-recipients')
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
+7 -2
View File
@@ -1,7 +1,8 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// admin token counts, so the licence step can run during /setup.
@@ -9,5 +10,9 @@ router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
router.post('/update-request', protect, admin, requestUpdate);
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
router.post('/update-snooze', protect, admin, snoozeUpdate);
// The provider's notices: site admins and supervisors see them as a banner.
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
module.exports = router;
+47
View File
@@ -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;
+33
View File
@@ -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
View File
@@ -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',
};
/**
+4
View File
@@ -102,6 +102,10 @@ const RESOLVERS = {
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. */
+93
View File
@@ -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 };
+844
View File
@@ -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,
};
+1
View File
@@ -1513,6 +1513,7 @@ async function sendAttendeeFormLinkMessage(attendee) {
}
module.exports = {
getEventNotifyEmails,
buildEventNotifyRecipientList,
sendAttendeeFormLinkMessage,
sendRegistrationEmails,
+18 -2
View File
@@ -455,7 +455,15 @@ async function anonymiseUser(userId, db = prisma) {
await tx.passwordReset.deleteMany({ where: { userId } });
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length };
// Kids check-in: leave their family; a family with no parent left is deleted with its
// children (their check-ins stay as attendance, without who collected them).
const { familyDeleted } = await require('./kids').removeFromFamily(tx, userId);
await tx.kidsCheckIn.updateMany({ where: { checkedOutById: userId }, data: { checkedOutById: null } });
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
await clearTrustedDevices(userId);
@@ -477,7 +485,7 @@ async function exportUserData(userId, db = prisma) {
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -501,6 +509,8 @@ async function exportUserData(userId, db = prisma) {
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
require('./kids').exportFamily(userId, db),
require('./volunteers').exportVolunteering(userId, db),
]);
return {
@@ -542,6 +552,8 @@ async function exportUserData(userId, db = prisma) {
feedback: feedback.map(f => ({ event: f.form?.event?.title, at: f.createdAt, answers: f.answers.map(a => ({ question: a.field?.label, answer: a.value })) })),
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
dataRequests: requests,
kidsFamily: family,
volunteering,
};
}
@@ -598,6 +610,10 @@ async function dataExportPdf(data) {
heading('Certificates');
for (const c of data.certificates) para(`${c.event} — ${c.code}${c.revoked ? ' (withdrawn)' : ''}`);
}
if (data.volunteering?.length) {
heading('Serving (volunteer rosters)');
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
}
if (data.feedback.length) {
heading('Feedback you gave');
for (const f of data.feedback) {
+30 -10
View File
@@ -24,11 +24,12 @@ const DEFAULT_SECTIONS = [
title: 'Plain English Summary',
body: [
"We respect your privacy — here's what you need to know:",
'• We only collect info needed to run events: registrations, payments, tickets and check-in.',
'• We store your info safely on South African servers.',
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
'• Online payments go through Yoco or PayFast; we never see your card details.',
'• We send tickets, updates and security alerts by email and/or WhatsApp. News and announcements are sent only if you ask for them, and you can unsubscribe at any time.',
'• Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.',
@@ -50,8 +51,10 @@ const DEFAULT_SECTIONS = [
'We collect the following information when you interact with our platform:',
'• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.',
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
'• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.',
'• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).',
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
'• Your home campus, if the organisation has more than one campus and you choose one.',
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
'• Your choices: whether you want news and announcements, which version of this notice you accepted, and when you made each choice.',
@@ -83,7 +86,10 @@ const DEFAULT_SECTIONS = [
'• Send news and announcements about upcoming events, only if you asked for them. Every such email has an unsubscribe link, and you can reply STOP on WhatsApp.',
'• Send security notifications for new logins, password changes, and account actions on your account.',
'• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).',
'• Check tickets in at the door, including with scanners that keep working without an internet connection.',
'• Check tickets in at the door, including with scanners that keep working without an internet connection, and keep attendance per session on multi-session events.',
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
'• Show you the events of your home campus first, if the organisation has more than one campus.',
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
'• Send you an optional feedback form after an event, with one reminder.',
'• Manage user accounts and event attendance records, and give support when you ask for help.',
@@ -99,7 +105,9 @@ const DEFAULT_SECTIONS = [
'• Event registration confirmations and updates.',
'• Payment receipts.',
'• Your event tickets (as a PDF document).',
'• Security alerts such as new login notifications, password changes, and account closure confirmations.',
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
'• Session reminders, if the event sends them.',
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
'Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.',
@@ -113,7 +121,8 @@ const DEFAULT_SECTIONS = [
id: 'storage',
title: '6. Where We Store Your Data',
body: [
'All data is securely stored on cloud servers hosted in South Africa.',
"Your information is stored in this site's own database, on the servers of the hosting provider listed in section 9 (where they are is shown there). Each organisation using this software has its own separate site and database.",
'The software (Cross Events) is licensed from Cross Code. Every 15 minutes the site sends Cross Code\'s licence server technical health information (software version, uptime, disk space, errors) and totals — such as the number of accounts, events, registrations, tickets and check-ins, and income as a broad range (for example "R5 001–R20 000"). It never sends names, contact details, children\'s details or any other personal information.',
'We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.',
].join('\n'),
},
@@ -124,18 +133,25 @@ const DEFAULT_SECTIONS = [
'We keep personal information only as long as we need it:',
'{retention}',
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
].join('\n'),
},
{
id: 'children',
title: '8. Children, Parental Consent and Health Information',
title: '8. Children, Parental Consent, Kids Check-in and Health Information',
body: [
'Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.',
'We do not knowingly collect or process personal information of minors without such consent.',
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
'A parent or guardian may also say whether photos of the young person may be used.',
'Kids check-in:',
"• A parent adds their children under My account → Kids, or the kids desk adds them with the parent present. A parent who is added at the desk with only a phone number or email address gets an account they can activate.",
"• Allergies, medical notes and custody notes (for example, someone who may not collect a child) are special personal information. We keep them only to keep your child safe, with your consent as their parent or guardian. They are stored encrypted, shown only to kids-ministry staff at the desk and in the rooms, and never put in a message. Opening the desk or room screens is recorded. Only parents, supervisors and administrators can change custody notes, and every change is recorded.",
'• Each check-in gives you a pickup code. Anyone who has the code can collect your children, so share it only with people you trust. The adults you list as allowed to collect are shown to the room leader so they can check.',
'• Each child is added to the event\'s attendance records (name, date of birth and gender) like any other attendee, so the organisation knows who attended.',
"• A child's details are deleted when they haven't been checked in for the period in section 7. You can remove them yourself at any time.",
].join('\n'),
},
{
@@ -147,7 +163,9 @@ const DEFAULT_SECTIONS = [
'• Anyone we are required to share it with under South African law.',
'Within our organisation, people see only what they need:',
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.',
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
'• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.',
@@ -186,6 +204,8 @@ const DEFAULT_SECTIONS = [
'Staff and administrators only:',
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
'Optional — only if you click "Allow" on the cookie notice:',
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
'Third parties:',
@@ -198,7 +218,7 @@ const DEFAULT_SECTIONS = [
id: 'security',
title: '12. Security',
body: [
'Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.',
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details, and children's allergies, medical and custody notes, are additionally encrypted in our database.",
'Passwords are encrypted and cannot be viewed by anyone, including administrators.',
'Two-factor authentication is available to everyone and is required for supervisors and administrators.',
'Links we send for consent, feedback, ticket transfers and unsubscribing are personal. Only a secure fingerprint of most links is stored, not the link itself.',
+4
View File
@@ -79,6 +79,10 @@ async function readiness(now = new Date(), db = prisma) {
add('whatsapp_stop', 'WhatsApp STOP replies reach the site', seen && now - seen < 30 * DAY,
seen ? `Last message from WAWP ${seen.toLocaleString('en-ZA')}${now - seen >= 30 * DAY ? ' — over a month ago, check the webhook' : ''}` : 'Paste the webhook URL from Site settings → WhatsApp into WAWP (events: message, session.status).',
'/dashboard/admin/settings?tab=whatsapp', whatsappConfigured);
const kidsEvents = await db.event.count({ where: { kidsCheckIn: true } });
add('kids_retention', 'Children\'s kids check-in details expire', policies.policies.kids_profiles?.enabled,
policies.policies.kids_profiles?.enabled ? `Deleted after ${retention.describeMonths(policies.policies.kids_profiles.months)} without a check-in` : 'Switch on the "Children at kids check-in" retention rule — it holds children\'s medical and custody notes.',
'/dashboard/admin/privacy?tab=retention', kidsEvents > 0);
const twoFactorOn = String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() !== 'false';
add('two_factor', 'Two-factor login required for admins and supervisors', twoFactorOn,
twoFactorOn ? 'Required' : 'TWO_FACTOR_REQUIRED_ROLES=false on the server — only for emergencies.', null);
+15 -1
View File
@@ -43,6 +43,12 @@ const POLICIES = {
defaults: { enabled: true, months: 24 },
minMonths: 3,
},
kids_profiles: {
label: 'Children at kids check-in',
description: 'A child\'s profile (name, date of birth, allergies, medical and custody notes) is deleted when they haven\'t been checked in for this long, then families left without children. Who collected a child is cleared from check-ins this old; the attendance itself stays.',
defaults: { enabled: true, months: 6 },
minMonths: 1,
},
audit_logs: {
label: 'Audit and access logs',
description: 'The audit log, the access log and "view as" sessions.',
@@ -227,7 +233,12 @@ async function runAuditLogs(policy, now, db = prisma) {
return { audit: audit.count, access: access.count, viewAsSessions: sessions.count };
}
async function runKidsProfiles(policy, now, db = prisma) {
return require('./kids').runKidsRetention(monthsAgo(policy.months, now), db);
}
const RUNNERS = {
kids_profiles: runKidsProfiles,
inactive_accounts: runInactiveAccounts,
form_answers: runFormAnswers,
login_history: runLoginHistory,
@@ -267,12 +278,13 @@ async function preview(policies, now = new Date(), db = prisma) {
const out = {};
const p = policies;
const inactiveCutoff = monthsAgo(p.inactive_accounts.months, now);
const [toWarn, warnedDue, formAnswers, logins, audit] = await Promise.all([
const [toWarn, warnedDue, formAnswers, logins, audit, kidsProfiles] = await Promise.all([
inactiveAccounts(new Date(inactiveCutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: 5000 }, db),
inactiveAccounts(inactiveCutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: 5000 }, db),
db.formAnswer.count({ where: { response: { registration: { event: { endDate: { lt: monthsAgo(p.form_answers.months, now) } } } } } }),
db.securityEvent.count({ where: { createdAt: { lt: monthsAgo(p.login_history.months, now) } } }),
db.adminAuditLog.count({ where: { createdAt: { lt: monthsAgo(p.audit_logs.months, now) } } }),
require('./kids').previewKidsRetention(monthsAgo(p.kids_profiles.months, now), db),
]);
const payments = await db.$queryRawUnsafe(
`SELECT COUNT(*)::int AS n FROM "ProviderTransaction" WHERE "createdAt" < $1 AND "raw" IS NOT NULL AND jsonb_typeof("raw") = 'object' AND NOT ("raw" ? '_scrubbed')`,
@@ -283,6 +295,7 @@ async function preview(policies, now = new Date(), db = prisma) {
out.login_history = { count: logins };
out.payment_details = { count: payments[0]?.n || 0 };
out.audit_logs = { count: audit };
out.kids_profiles = kidsProfiles;
return out;
}
@@ -298,6 +311,7 @@ async function noticeLines(db = prisma) {
`• Medical and emergency-contact details from parental consent forms are deleted ${parseInt(medical, 10) || 30} days after the event. The record of who gave consent, when and to which wording is kept.`,
line('login_history', t => `• Login history (time, IP address, device) is kept for ${t}.`, '• Login history is kept until you ask us to delete it.'),
line('payment_details', t => `• Payer names and email addresses received with payments are removed after ${t}. Payment records themselves (amount, date, method) are kept as tax law requires.`, '• Payment records are kept as tax law requires.'),
line('kids_profiles', t => `• Children's details for kids check-in (including allergies, medical and custody notes) are deleted when a child hasn't been checked in for ${t}.`, '• Children\'s details for kids check-in are kept until a parent removes them.'),
line('audit_logs', t => `• Audit and access records (who did what in the system) are kept for ${t}.`, '• Audit and access records are kept for accountability.'),
'• Detailed visit statistics become anonymous daily totals after 90 days.',
`• Database backups are kept for about ${parseInt(backups, 10) || 14} days, so deleted information stays in them until they are replaced.`,
+782
View File
@@ -0,0 +1,782 @@
/**
* Volunteer rosters (1.46.0). Who serves where at an event:
*
* - Serving roles (Welcome, Parking, Kids, Sound…) belong to one event, or — on a multi-session
* event — to one session, with the number of people needed and optional shift times (default:
* the session's or event's times). Rosters never repeat: each event/session has its own roles.
* - Anyone with an account can sign up for a role that isn't full; supervisors can also assign
* people (even beyond the number needed). Nobody can be in two roles whose shifts overlap.
* - Volunteers get a reminder the day before their shift. "Can't make it" asks for cover: the
* request shows on the Serving page and goes to the event's other volunteers, and the place
* moves as soon as someone accepts — the event's notify recipients are told, nobody approves.
* - On the day volunteers check themselves in by scanning the event's volunteer QR code (or staff
* tick them in). A role linked to a kids room puts its volunteers on duty in that room when they
* check in, so they count for the room's leader-to-children ratio.
* - A free volunteer place on a ticket of the event is off by default; a supervisor turns it on
* per event by choosing the ticket. Volunteers who already have a ticket keep theirs.
*/
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
const sessionsUtil = require('./sessions');
const MAX_NEEDED = 200;
const MAX_ROLES = 100;
const HOUR = 60 * 60 * 1000;
// Reminders go out this long before a shift, to people who signed up before then.
const REMIND_BEFORE_MS = 24 * HOUR;
// What the check-in QR code holds: the prefix, then the event's token.
const QR_PREFIX = 'SERVE-';
function volunteerError(message, statusCode = 400, code = undefined) {
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** A role's shift: its own times, else its session's, else the event's. */
function shiftOf(role, event) {
const session = role.session || (role.sessionId ? (event.sessions || []).find(s => s.id === role.sessionId) : null);
return {
startsAt: new Date(role.startsAt || session?.startsAt || event.startDate),
endsAt: new Date(role.endsAt || session?.endsAt || event.endDate),
};
}
/** Whether two shifts overlap (touching ends don't count: 08:00–10:00 and 10:00–12:00 are fine). */
function overlaps(a, b) {
return new Date(a.startsAt) < new Date(b.endsAt) && new Date(b.startsAt) < new Date(a.endsAt);
}
/** Hours in a shift, to the nearest quarter hour. */
function shiftHours(shift) {
const h = (new Date(shift.endsAt) - new Date(shift.startsAt)) / HOUR;
return h > 0 ? Math.round(h * 4) / 4 : 0;
}
/** { needed, filled, open, full } for a role with `filled` volunteers. */
function fillOf(needed, filled) {
return { needed, filled, open: Math.max(0, needed - filled), full: filled >= needed };
}
/** Volunteers can check in on the day(s) of their shift, until it ends. */
function checkInOpen(shift, now = new Date()) {
const today = sessionsUtil.dayKey(now);
return today >= sessionsUtil.dayKey(shift.startsAt) && today <= sessionsUtil.dayKey(shift.endsAt) && new Date(now) <= new Date(shift.endsAt);
}
/**
* Whether a slot's reminder is due: not sent yet, the shift starts within the next 24 hours, and
* the person signed up more than half an hour ago (someone who just signed up doesn't need one).
*/
function reminderDue({ remindedAt, createdAt }, shift, now = new Date()) {
if (remindedAt) return false;
const t = new Date(now).getTime();
const start = new Date(shift.startsAt).getTime();
return start > t && start - t <= REMIND_BEFORE_MS && t - new Date(createdAt).getTime() > HOUR / 2;
}
/** The kids room occasion a check-in counts for (see utils/kids.js occasionFor): the session, or today. */
function kidsOccasion(role, now = new Date()) {
return role.sessionId || sessionsUtil.dayKey(now);
}
/** Ticket options only staff hand out — the kids check-in and volunteer tickets — never sold publicly. */
function reservedOptionIds(event) {
return [event?.kidsOptionId, event?.volunteerOptionId].filter(Boolean);
}
/** The event's options without the reserved ones (for public event pages and lists). */
function publicOptions(event) {
const reserved = new Set(reservedOptionIds(event));
return (event?.eventOptions || []).filter(o => !reserved.has(o.id));
}
function fmtWhen(d) {
return new Date(d).toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function fmtTime(d) {
return new Date(d).toLocaleTimeString('en-GB', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' });
}
/** "Sat 7 Nov, 08:00–10:30" (the end date is added when the shift runs past midnight). */
function shiftText(shift) {
const sameDay = sessionsUtil.dayKey(shift.startsAt) === sessionsUtil.dayKey(shift.endsAt);
return `${fmtWhen(shift.startsAt)}–${sameDay ? fmtTime(shift.endsAt) : fmtWhen(shift.endsAt)}`;
}
function cleanText(v, max) {
return String(v ?? '').trim().slice(0, max);
}
// ─── Loading ──────────────────────────────────────────────────────────────────
const EVENT_SELECT = {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true, location: true, campusId: true,
kidsCheckIn: true, kidsRoomSetId: true, kidsOptionId: true, volunteerOptionId: true, volunteerCheckInToken: true,
sessions: { orderBy: { startsAt: 'asc' } },
};
async function loadEvent(eventId, db = prisma) {
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, select: EVENT_SELECT });
if (!event) throw volunteerError('Event not found', 404);
return event;
}
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true };
const ROLE_INCLUDE = {
session: true,
kidsRoom: { select: { id: true, name: true } },
slots: {
orderBy: { createdAt: 'asc' },
include: { user: { select: USER_SELECT }, assignedBy: { select: { name: true } }, checkedInBy: { select: { name: true } } },
},
swaps: { where: { status: 'open' }, orderBy: { createdAt: 'asc' } },
};
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
/**
* A role for the API. `staff` adds the volunteers (names, contact, check-ins); others see only
* how full it is, whether they're in it, and open cover requests.
*/
function roleView(role, event, { staff = false, userId = null, now = new Date() } = {}) {
const shift = shiftOf(role, event);
const sessions = sessionsUtil.activeSessions(event.sessions);
const mine = userId ? role.slots.find(s => s.userId === userId) : null;
const swapBySlot = Object.fromEntries(role.swaps.map(w => [w.slotId, w]));
return {
id: role.id,
eventId: role.eventId,
sessionId: role.sessionId,
sessionLabel: role.session ? sessionsUtil.sessionLabel(role.session, sessions) : null,
name: role.name,
description: role.description || '',
startsAt: role.startsAt,
endsAt: role.endsAt,
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
kidsRoom: role.kidsRoom || null,
order: role.order,
past: new Date(shift.endsAt) < new Date(now),
...fillOf(role.needed, role.slots.length),
mine: mine ? { slotId: mine.id, checkedInAt: mine.checkedInAt, swapId: swapBySlot[mine.id]?.id || null } : null,
coverNeeded: role.swaps.filter(w => w.fromUserId !== userId).map(w => ({ swapId: w.id, note: w.note || '' })),
...(staff ? {
volunteers: role.slots.map(s => ({
slotId: s.id,
userId: s.user.id,
name: s.user.name,
phone: s.user.phoneNumber || null,
email: realEmail(s.user.email),
assignedBy: s.assignedBy?.name || null,
checkedInAt: s.checkedInAt,
checkedInBy: s.checkedInBy?.name || null,
checkInMethod: s.checkInMethod,
swapOpen: !!swapBySlot[s.id],
hasTicket: !!s.registrationId,
})),
} : {}),
};
}
/** An event's roster: its roles in order (by session, then shift start, then order). */
async function roster(eventId, { staff = false, userId = null, now = new Date() } = {}, db = prisma) {
const event = await loadEvent(eventId, db);
const roles = await db.servingRole.findMany({ where: { eventId: event.id }, include: ROLE_INCLUDE });
const views = roles.map(r => roleView(r, event, { staff, userId, now }))
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt) || a.order - b.order || a.name.localeCompare(b.name));
const totals = views.reduce((t, r) => ({ needed: t.needed + r.needed, filled: t.filled + Math.min(r.filled, r.needed), checkedIn: t.checkedIn + (r.volunteers || []).filter(v => v.checkedInAt).length }), { needed: 0, filled: 0, checkedIn: 0 });
return {
event: { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, scheduleType: event.scheduleType, location: event.location, kidsCheckIn: event.kidsCheckIn },
sessions: sessionsUtil.activeSessions(event.sessions).map(s => ({ id: s.id, label: sessionsUtil.sessionLabel(s, event.sessions), startsAt: s.startsAt, endsAt: s.endsAt })),
roles: views,
totals: { ...totals, fillRate: totals.needed ? Math.round((totals.filled / totals.needed) * 100) : null },
};
}
// ─── Roles (supervisors) ──────────────────────────────────────────────────────
function parseTime(v, what) {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (Number.isNaN(d.getTime())) throw volunteerError(`Enter a valid ${what}.`);
return d;
}
/** Check and clean a role from the request (`existing` when changing one). */
async function cleanRole(event, input, existing = null, db = prisma) {
const b = input || {};
const data = {};
if (b.name !== undefined || !existing) {
data.name = cleanText(b.name, 60);
if (!data.name) throw volunteerError('Enter a name for the role (e.g. Welcome, Parking, Sound).');
}
if (b.description !== undefined) data.description = cleanText(b.description, 500) || null;
if (b.needed !== undefined || !existing) {
const n = parseInt(b.needed ?? 1, 10);
if (!Number.isFinite(n) || n < 1 || n > MAX_NEEDED) throw volunteerError(`The number of people needed must be from 1 to ${MAX_NEEDED}.`);
data.needed = n;
}
if (b.sessionId !== undefined || !existing) {
if (event.scheduleType === 'sessions') {
const session = event.sessions.find(s => s.id === b.sessionId && !s.cancelledAt);
if (!session) throw volunteerError('Choose the session this role is for.');
data.sessionId = session.id;
} else data.sessionId = null;
}
const startsAt = parseTime(b.startsAt, 'start time');
const endsAt = parseTime(b.endsAt, 'end time');
if (startsAt !== undefined) data.startsAt = startsAt;
if (endsAt !== undefined) data.endsAt = endsAt;
const shift = shiftOf({ ...existing, ...data, session: undefined }, event);
if (shift.endsAt <= shift.startsAt) throw volunteerError('The shift has to end after it starts.');
if (b.kidsRoomId !== undefined) {
if (!b.kidsRoomId) data.kidsRoomId = null;
else {
const room = event.kidsRoomSetId ? await db.allocationGroup.findFirst({ where: { id: String(b.kidsRoomId), setId: event.kidsRoomSetId } }) : null;
if (!room) throw volunteerError('Choose one of this event\'s kids rooms (set up under Kids check-in setup).');
data.kidsRoomId = room.id;
}
}
if (b.order !== undefined) data.order = Math.max(0, Math.min(1000, parseInt(b.order, 10) || 0));
return data;
}
async function createRole(eventId, input, db = prisma) {
const event = await loadEvent(eventId, db);
if (await db.servingRole.count({ where: { eventId: event.id } }) >= MAX_ROLES) throw volunteerError(`An event can have up to ${MAX_ROLES} serving roles.`);
const data = await cleanRole(event, input, null, db);
if (input?.order === undefined) data.order = await db.servingRole.count({ where: { eventId: event.id, sessionId: data.sessionId } });
return db.servingRole.create({ data: { ...data, eventId: event.id } });
}
async function updateRole(roleId, input, db = prisma) {
const existing = await db.servingRole.findUnique({ where: { id: String(roleId || '') } });
if (!existing) throw volunteerError('Role not found', 404);
const event = await loadEvent(existing.eventId, db);
const data = await cleanRole(event, input, existing, db);
if (data.sessionId !== undefined && data.sessionId !== existing.sessionId && await db.volunteerSlot.count({ where: { roleId: existing.id } })) {
throw volunteerError('People have signed up for this role, so it can\'t move to another session — add a new role there instead.');
}
return db.servingRole.update({ where: { id: existing.id }, data });
}
/**
* Delete a role. Refused once someone has checked in for it (it counts for hours served).
* Returns the volunteers who were on it, so they can be told.
*/
async function deleteRole(roleId, db = prisma) {
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { slots: { include: { user: { select: USER_SELECT } } } } });
if (!role) throw volunteerError('Role not found', 404);
if (role.slots.some(s => s.checkedInAt)) throw volunteerError('Someone has already checked in for this role, so it stays on the roster (it counts for hours served).');
const event = await loadEvent(role.eventId, db);
await db.$transaction(async tx => {
for (const s of role.slots) await releasePlace(tx, s);
await tx.servingRole.delete({ where: { id: role.id } });
});
return { role, event, volunteers: role.slots.map(s => s.user) };
}
// ─── Settings ─────────────────────────────────────────────────────────────────
/** The event's volunteer settings and the tickets that can be the volunteer ticket. */
async function getSettings(eventId, db = prisma) {
const event = await loadEvent(eventId, db);
const [options, rooms] = await Promise.all([
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
event.kidsRoomSetId ? db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, select: { id: true, name: true }, orderBy: { order: 'asc' } }) : [],
]);
return {
event: { id: event.id, title: event.title, volunteerOptionId: event.volunteerOptionId },
options: options.filter(o => o.id !== event.kidsOptionId),
kidsRooms: rooms,
};
}
/**
* Turn the free volunteer place on or off. `optionId: 'new'` adds a free "Volunteer" main ticket
* (staff-only: it never shows publicly); null switches it off (places already given stay).
*/
async function saveSettings(eventId, { optionId }, db = prisma) {
const event = await loadEvent(eventId, db);
let next = null;
if (optionId === 'new') {
next = (await db.eventOption.create({ data: { eventId: event.id, name: 'Volunteer', price: 0, isMainTicket: true, sessionMode: 'all' } })).id;
} else if (optionId) {
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
if (!opt || opt.id === event.kidsOptionId) throw volunteerError('Choose a main ticket of this event that covers the whole event (not the kids ticket).');
next = opt.id;
}
await db.event.update({ where: { id: event.id }, data: { volunteerOptionId: next } });
return getSettings(event.id, db);
}
/** The event's check-in QR value (the token is made the first time). */
async function checkInQr(eventId, db = prisma) {
const event = await loadEvent(eventId, db);
let token = event.volunteerCheckInToken;
if (!token) {
token = crypto.randomBytes(12).toString('hex');
await db.event.update({ where: { id: event.id }, data: { volunteerCheckInToken: token } });
}
return { token, qr: QR_PREFIX + token };
}
// ─── The free volunteer place ─────────────────────────────────────────────────
/**
* Give a volunteer a free place on the event's volunteer ticket, when that's switched on and they
* have no ticket for the event yet. Returns the registration id (shared by their slots), or null.
*/
async function givePlace(tx, event, userId) {
if (!event.volunteerOptionId) return null;
const shared = await tx.volunteerSlot.findFirst({ where: { userId, registrationId: { not: null }, role: { eventId: event.id }, registration: { status: { not: 'cancelled' } } }, select: { registrationId: true } });
if (shared) return shared.registrationId;
const hasTicket = await tx.ticket.count({ where: { eventId: event.id, OR: [{ userId }, { holderUserId: userId }], registrationOption: { registration: { status: { not: 'cancelled' } } } } });
if (hasTicket) return null;
const registration = await tx.registration.create({ data: { userId, eventId: event.id, status: 'paid' } });
const line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.volunteerOptionId, quantity: 1, priceSnapshot: 0 } });
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
await tx.ticket.create({ data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId, eventId: event.id, quantity: 1, emailSent: true } });
return registration.id;
}
/**
* When a slot goes (withdrawn, removed, swapped away): cancel its free place if no other slot of
* the person at the event still uses it and the ticket was never scanned.
*/
async function releasePlace(tx, slot) {
if (!slot.registrationId) return;
const others = await tx.volunteerSlot.count({ where: { registrationId: slot.registrationId, id: { not: slot.id }, userId: slot.userId } });
if (others) return;
const scanned = await tx.ticketUsage.count({ where: { ticket: { registrationOption: { registrationId: slot.registrationId } } } });
if (scanned) return;
await tx.registration.updateMany({ where: { id: slot.registrationId, status: { not: 'cancelled' } }, data: { status: 'cancelled' } });
}
// ─── Signing up ───────────────────────────────────────────────────────────────
async function loadRole(roleId, db = prisma) {
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { session: true, slots: { select: { id: true, userId: true } } } });
if (!role) throw volunteerError('Role not found', 404);
return role;
}
/** Refuse a role whose shift is over, a cancelled session, or an inactive event. */
function assertOpen(role, event, now = new Date()) {
if (!event.isActive) throw volunteerError('This event isn\'t open any more.');
if (role.session?.cancelledAt) throw volunteerError('That session was cancelled.');
if (shiftOf(role, event).endsAt <= new Date(now)) throw volunteerError('This shift is already over.');
}
/** Refuse when the person serves in another role at the same time. */
async function assertFree(tx, event, role, userId) {
const shift = shiftOf(role, event);
const theirs = await tx.volunteerSlot.findMany({ where: { userId, roleId: { not: role.id }, role: { eventId: event.id } }, include: { role: { include: { session: true } } } });
const clash = theirs.find(s => overlaps(shiftOf(s.role, event), shift));
if (clash) throw volunteerError(`${clash.role.name} (${shiftText(shiftOf(clash.role, event))}) is at the same time.`, 409, 'SHIFT_CLASH');
}
/**
* Put someone on a role: they signed up (`assignedById` null — refused when the role is full), or
* a supervisor assigned them (allowed beyond the number needed).
*/
async function signUp({ roleId, userId, assignedById = null, now = new Date() }, db = prisma) {
const role = await loadRole(roleId, db);
const event = await loadEvent(role.eventId, db);
assertOpen(role, event, now);
if (role.slots.some(s => s.userId === userId)) throw volunteerError(assignedById ? 'This person is already on this role.' : 'You\'re already serving in this role.', 409);
const slot = await db.$transaction(async tx => {
if (!assignedById && await tx.volunteerSlot.count({ where: { roleId: role.id } }) >= role.needed) throw volunteerError('This role is full — thank you for offering!', 409, 'ROLE_FULL');
await assertFree(tx, event, role, userId);
const registrationId = await givePlace(tx, event, userId);
return tx.volunteerSlot.create({ data: { roleId: role.id, userId, assignedById, registrationId } });
});
return { slot, role, event };
}
async function loadSlot(slotId, db = prisma) {
const slot = await db.volunteerSlot.findUnique({ where: { id: String(slotId || '') }, include: { user: { select: USER_SELECT }, role: { include: { session: true } } } });
if (!slot) throw volunteerError('Not found', 404);
return slot;
}
/** Take someone off a role (they withdrew, or a supervisor removed them). Not after check-in. */
async function leave({ slotId, userId = null }, db = prisma) {
const slot = await loadSlot(slotId, db);
if (userId && slot.userId !== userId) throw volunteerError('Not found', 404);
if (slot.checkedInAt) throw volunteerError(`${userId ? 'You\'ve' : `${slot.user.name} has`} already checked in for this shift.`);
const event = await loadEvent(slot.role.eventId, db);
if (userId && shiftOf(slot.role, event).endsAt <= new Date()) throw volunteerError('This shift is already over.');
await db.$transaction(async tx => {
await tx.volunteerSwap.updateMany({ where: { slotId: slot.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
await releasePlace(tx, slot);
await tx.volunteerSlot.delete({ where: { id: slot.id } });
});
return { slot, event };
}
// ─── Swaps ────────────────────────────────────────────────────────────────────
/** "Can't make it": ask for someone to take over the slot. One open request per slot. */
async function requestSwap({ slotId, userId, note, now = new Date() }, db = prisma) {
const slot = await loadSlot(slotId, db);
if (slot.userId !== userId) throw volunteerError('Not found', 404);
const event = await loadEvent(slot.role.eventId, db);
assertOpen(slot.role, event, now);
if (slot.checkedInAt) throw volunteerError('You\'ve already checked in for this shift.');
const open = await db.volunteerSwap.findFirst({ where: { slotId: slot.id, status: 'open' } });
if (open) return { swap: open, slot, event, created: false };
const swap = await db.volunteerSwap.create({ data: { slotId: slot.id, roleId: slot.roleId, fromUserId: userId, note: cleanText(note, 300) || null } });
return { swap, slot, event, created: true };
}
async function cancelSwap({ swapId, userId }, db = prisma) {
const out = await db.volunteerSwap.updateMany({ where: { id: String(swapId || ''), fromUserId: userId, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
if (!out.count) throw volunteerError('Not found', 404);
return { cancelled: true };
}
/**
* Take over a slot someone can't make. Happens at once: the slot moves to the person accepting
* (their reminder and free place are theirs now). Returns { swap, slot, from, to, event }.
*/
async function acceptSwap({ swapId, userId, now = new Date() }, db = prisma) {
const swap = await db.volunteerSwap.findUnique({ where: { id: String(swapId || '') } });
if (!swap || swap.status !== 'open' || !swap.slotId) throw volunteerError('Someone has already taken this place, or it isn\'t needed any more.', 409, 'SWAP_CLOSED');
if (swap.fromUserId === userId) throw volunteerError('This is your own request — cancel it instead.');
const slot = await loadSlot(swap.slotId, db);
const event = await loadEvent(slot.role.eventId, db);
assertOpen(slot.role, event, now);
await db.$transaction(async tx => {
// Claim the request first, so two people accepting at once can't both take it.
const claimed = await tx.volunteerSwap.updateMany({ where: { id: swap.id, status: 'open' }, data: { status: 'accepted', toUserId: userId, resolvedAt: new Date(now) } });
if (!claimed.count) throw volunteerError('Someone has just taken this place.', 409, 'SWAP_CLOSED');
if (await tx.volunteerSlot.count({ where: { roleId: slot.roleId, userId } })) throw volunteerError('You\'re already serving in this role.', 409);
await assertFree(tx, event, slot.role, userId);
await releasePlace(tx, slot);
const registrationId = await givePlace(tx, event, userId);
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { userId, assignedById: null, registrationId, remindedAt: null } });
});
const to = await db.user.findUnique({ where: { id: userId }, select: USER_SELECT });
return { swap, slot, from: slot.user, to, event };
}
/** Open cover requests the person could take (not their own, not a role they're in, not over). */
async function openSwaps({ userId, now = new Date() }, db = prisma) {
const swaps = await db.volunteerSwap.findMany({
where: { status: 'open', fromUserId: { not: userId }, role: { event: { isActive: true, endDate: { gte: now } }, slots: { none: { userId } } } },
include: { role: { include: { session: true, event: { select: EVENT_SELECT } } }, fromUser: { select: { name: true } } },
orderBy: { createdAt: 'asc' },
take: 50,
});
return swaps
.map(w => ({ w, shift: shiftOf(w.role, w.role.event) }))
.filter(({ shift }) => shift.endsAt > new Date(now))
.map(({ w, shift }) => ({
swapId: w.id, note: w.note || '', from: (w.fromUser?.name || '').split(' ')[0] || null,
role: { id: w.role.id, name: w.role.name, sessionLabel: w.role.session ? sessionsUtil.sessionLabel(w.role.session, w.role.event.sessions) : null },
event: { id: w.role.event.id, title: w.role.event.title },
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift) },
}));
}
// ─── Check-in ─────────────────────────────────────────────────────────────────
/** Put a checked-in volunteer of a kids-room role on duty in that room for the occasion. */
async function startKidsDuty(tx, event, role, userId, now) {
if (!role.kidsRoomId || !event.kidsCheckIn || !event.kidsRoomSetId) return false;
const room = await tx.allocationGroup.findFirst({ where: { id: role.kidsRoomId, setId: event.kidsRoomSetId }, select: { id: true } });
if (!room) return false;
const occasion = kidsOccasion(role, now);
const already = await tx.kidsRoomDuty.findFirst({ where: { userId, occasion, roomId: room.id, endedAt: null } });
if (already) return true;
// One room at a time, as with "I'm on duty".
await tx.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null }, data: { endedAt: new Date(now) } });
await tx.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId, fromRoster: true, startedAt: new Date(now) } });
return true;
}
async function markCheckedIn(tx, event, slot, { by, method, now }) {
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: new Date(now), checkedInById: by, checkInMethod: method } });
return startKidsDuty(tx, event, slot.role, slot.userId, now);
}
/**
* A volunteer scanned the event's check-in QR code: check in every slot of theirs whose shift is
* on today. Returns { event, checkedIn: [{ role, kidsRoom }], already }.
*/
async function checkInWithQr({ qr, userId, now = new Date() }, db = prisma) {
const token = String(qr || '').trim().replace(new RegExp(`^${QR_PREFIX}`, 'i'), '');
const found = token ? await db.event.findUnique({ where: { volunteerCheckInToken: token }, select: { id: true } }) : null;
if (!found) throw volunteerError('That isn\'t a volunteer check-in code from this site.', 404, 'BAD_QR');
const event = await loadEvent(found.id, db);
const slots = await db.volunteerSlot.findMany({ where: { userId, role: { eventId: event.id } }, include: { role: { include: { session: true, kidsRoom: { select: { name: true } } } } } });
if (!slots.length) throw volunteerError(`You're not on the volunteer roster for ${event.title}.`, 404, 'NOT_ROSTERED');
const today = slots.filter(s => !s.role.session?.cancelledAt && checkInOpen(shiftOf(s.role, event), now));
if (!today.length) {
const next = slots.map(s => shiftOf(s.role, event)).filter(sh => sh.endsAt > new Date(now)).sort((a, b) => a.startsAt - b.startsAt)[0];
throw volunteerError(next ? `Check-in opens on the day of your shift (${shiftText(next)}).` : 'Your shifts at this event are over.', 400, 'NOT_TODAY');
}
const checkedIn = [];
const already = [];
await db.$transaction(async tx => {
for (const s of today) {
if (s.checkedInAt) { already.push(s.role.name); continue; }
const onDuty = await markCheckedIn(tx, event, s, { by: userId, method: 'qr', now });
checkedIn.push({ role: s.role.name, kidsRoom: onDuty ? s.role.kidsRoom?.name || null : null });
}
});
return { event: { id: event.id, title: event.title }, checkedIn, already };
}
/** Staff tick a volunteer in (or undo it, which also ends their rostered kids-room duty). */
async function checkInManual({ slotId, staffUserId, undo = false, now = new Date() }, db = prisma) {
const slot = await loadSlot(slotId, db);
const event = await loadEvent(slot.role.eventId, db);
if (undo) {
if (!slot.checkedInAt) return { slot, changed: false };
await db.$transaction(async tx => {
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: null, checkedInById: null, checkInMethod: null } });
if (slot.role.kidsRoomId) await tx.kidsRoomDuty.updateMany({ where: { userId: slot.userId, roomId: slot.role.kidsRoomId, fromRoster: true, endedAt: null }, data: { endedAt: new Date(now) } });
});
return { slot, changed: true };
}
if (slot.checkedInAt) return { slot, changed: false };
if (!checkInOpen(shiftOf(slot.role, event), now) && new Date(now) < shiftOf(slot.role, event).startsAt) {
throw volunteerError(`${slot.user.name} can be checked in on the day of the shift (${shiftText(shiftOf(slot.role, event))}).`);
}
await db.$transaction(tx => markCheckedIn(tx, event, slot, { by: staffUserId, method: 'manual', now }));
return { slot, changed: true };
}
/** For the kids board: who is rostered in each room for an occasion, and whether they've checked in. */
async function rosteredForRooms(event, occasion, db = prisma) {
if (!event.kidsRoomSetId) return {};
const roles = await db.servingRole.findMany({
where: { eventId: event.id, kidsRoom: { setId: event.kidsRoomSetId }, ...(event.scheduleType === 'sessions' ? { sessionId: occasion } : {}) },
include: { session: true, slots: { include: { user: { select: { id: true, name: true } } } } },
});
const out = {};
for (const r of roles) {
// Single-date events: only roles whose shift is on that day.
if (event.scheduleType !== 'sessions') {
const shift = shiftOf(r, event);
if (occasion < sessionsUtil.dayKey(shift.startsAt) || occasion > sessionsUtil.dayKey(shift.endsAt)) continue;
}
for (const s of r.slots) {
(out[r.kidsRoomId] ||= []).push({ userId: s.user.id, name: s.user.name, checkedIn: !!s.checkedInAt });
}
}
return out;
}
// ─── My serving ───────────────────────────────────────────────────────────────
/** The person's upcoming (and the last week's) slots, with open swap requests. */
async function mySlots({ userId, now = new Date() }, db = prisma) {
const since = new Date(new Date(now).getTime() - 7 * 24 * HOUR);
const slots = await db.volunteerSlot.findMany({
where: { userId, role: { event: { endDate: { gte: since } } } },
include: { role: { include: { session: true, kidsRoom: { select: { name: true } }, event: { select: EVENT_SELECT } } }, swaps: { where: { status: 'open' } } },
});
return slots
.map(s => {
const shift = shiftOf(s.role, s.role.event);
return {
slotId: s.id,
role: { id: s.role.id, name: s.role.name, description: s.role.description || '', sessionLabel: s.role.session ? sessionsUtil.sessionLabel(s.role.session, s.role.event.sessions) : null, kidsRoom: s.role.kidsRoom?.name || null },
event: { id: s.role.event.id, title: s.role.event.title, location: s.role.event.location },
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
checkedInAt: s.checkedInAt,
canCheckIn: !s.checkedInAt && checkInOpen(shift, now),
past: shift.endsAt < new Date(now),
swapId: s.swaps[0]?.id || null,
hasTicket: !!s.registrationId,
};
})
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt));
}
/** Upcoming events with serving roles (for "Serve at an event"). */
async function eventsWithRoles({ now = new Date(), where = {} } = {}, db = prisma) {
return db.event.findMany({
where: { AND: [{ isActive: true, endDate: { gte: now }, servingRoles: { some: {} } }, where] },
select: { id: true, title: true, startDate: true, endDate: true, location: true, campusId: true, isHidden: true, _count: { select: { servingRoles: true } } },
orderBy: { startDate: 'asc' },
take: 100,
});
}
// ─── Reports ──────────────────────────────────────────────────────────────────
/**
* Hours served per person over the events matching `eventWhere` (checked-in shifts only), most
* hours first: [{ userId, name, shifts, hours, events }].
*/
async function hoursServed({ eventWhere = {}, from = null, to = null } = {}, db = prisma) {
const slots = await db.volunteerSlot.findMany({
where: { checkedInAt: { not: null, ...(from ? { gte: new Date(from) } : {}), ...(to ? { lte: new Date(to) } : {}) }, role: { event: eventWhere } },
include: { user: { select: { id: true, name: true } }, role: { include: { session: true, event: { select: { id: true, startDate: true, endDate: true, sessions: true } } } } },
});
const byUser = new Map();
for (const s of slots) {
const row = byUser.get(s.userId) || { userId: s.userId, name: s.user.name, shifts: 0, hours: 0, events: new Set() };
row.shifts += 1;
row.hours += shiftHours(shiftOf(s.role, s.role.event));
row.events.add(s.role.event.id);
byUser.set(s.userId, row);
}
return [...byUser.values()].map(r => ({ ...r, events: r.events.size })).sort((a, b) => b.hours - a.hours || a.name.localeCompare(b.name));
}
// ─── Messages ─────────────────────────────────────────────────────────────────
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
const servingUrl = () => `${frontendUrl()}/dashboard/user/serving`;
/** Send a text to a volunteer the way they like it (WhatsApp, email, or whichever works). Fire-and-forget. */
function tell(user, { subject, heading, text }) {
if (!user) return Promise.resolve([]);
return require('./kids').messageParent(user, { subject, heading, text }).catch(() => []);
}
/** Email the event's notify recipients (or its creator) about a roster change. */
async function tellOrganisers(eventId, { subject, text }, db = prisma) {
const event = await db.event.findUnique({
where: { id: eventId },
include: { createdBy: { select: { email: true } }, notifyRecipients: { select: { email: true } } },
});
const to = require('./notifications').getEventNotifyEmails(event);
if (!to.length) return;
const { sendMail, buildPrivacyEmail } = require('./email');
await sendMail({ to: to.join(','), subject, ...buildPrivacyEmail({ name: 'team', heading: subject, paragraphs: [text, `Roster: ${frontendUrl()}/dashboard/supervisor/volunteers?event=${eventId}`] }) }).catch(() => {});
}
function placeText(role, event) {
return `${role.name} at ${event.title}${role.session ? ` (${sessionsUtil.sessionLabel(role.session, event.sessions)})` : ''}, ${shiftText(shiftOf(role, event))}`;
}
function signedUpMessages({ user, role, event, by = null }) {
const text = by
? `${by} has put you on the volunteer roster: ${placeText(role, event)}. Thank you for serving! If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`
: `Thank you for volunteering: ${placeText(role, event)}. You'll get a reminder the day before. If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`;
return tell(user, { subject: `Serving: ${role.name} — ${event.title}`, heading: 'You\'re on the roster', text });
}
/** The cover request goes to the event's other volunteers (not the ones already in this role). */
async function sendSwapRequest({ swap, slot, event }, db = prisma) {
const others = await db.volunteerSlot.findMany({
where: { role: { eventId: event.id }, userId: { not: slot.userId }, NOT: { roleId: slot.roleId } },
distinct: ['userId'],
include: { user: { select: USER_SELECT } },
take: 200,
});
const inRole = new Set((await db.volunteerSlot.findMany({ where: { roleId: slot.roleId }, select: { userId: true } })).map(s => s.userId));
const first = (slot.user.name || '').split(' ')[0] || 'A volunteer';
const text = `${first} can't make it and is looking for someone to take over: ${placeText(slot.role, event)}.${swap.note ? ` "${swap.note}"` : ''} If you can, take it over in My account → Serving (${servingUrl()}) — the first to accept gets it.`;
for (const o of others) {
if (inRole.has(o.userId)) continue;
await tell(o.user, { subject: `Cover needed: ${slot.role.name} — ${event.title}`, heading: 'Can you take over?', text });
}
return others.length;
}
async function sendSwapAccepted({ slot, from, to, event }) {
const what = placeText(slot.role, event);
await tell(from, { subject: `${to.name} is taking over — ${event.title}`, heading: 'Your place is covered', text: `${to.name} has taken over ${what}. You're off the roster for it — thank you for finding cover.` });
await tell(to, { subject: `Serving: ${slot.role.name} — ${event.title}`, heading: 'You\'re on the roster', text: `Thank you for taking over from ${from.name}: ${what}. You'll get a reminder the day before.` });
await tellOrganisers(event.id, { subject: `Roster swap: ${slot.role.name} — ${event.title}`, text: `${to.name} has taken over from ${from.name}: ${what}.` });
}
async function sendLeft({ slot, event, byStaff = null }) {
const what = placeText(slot.role, event);
if (byStaff) {
await tell(slot.user, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `${byStaff} has taken you off the roster for ${what}. Thank you for being willing to serve.` });
} else {
await tellOrganisers(event.id, { subject: `Volunteer withdrew: ${slot.role.name} — ${event.title}`, text: `${slot.user.name} has withdrawn from ${what}.` });
}
}
async function sendRoleDeleted({ role, event, volunteers }) {
for (const u of volunteers) {
await tell(u, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `The ${role.name} role at ${event.title} is no longer needed, so you're off the roster for it. Thank you for being willing to serve.` });
}
}
/** Reminders for shifts starting within the next 24 hours. Returns { sent }. */
async function runReminderJob(now = new Date(), db = prisma) {
const slots = await db.volunteerSlot.findMany({
where: { remindedAt: null, checkedInAt: null, role: { event: { isActive: true, endDate: { gte: now }, startDate: { lte: new Date(new Date(now).getTime() + 31 * 24 * HOUR) } } } },
include: { user: { select: USER_SELECT }, role: { include: { session: true, event: { select: EVENT_SELECT } } } },
take: 2000,
});
let sent = 0;
for (const s of slots) {
if (s.role.session?.cancelledAt) continue;
const shift = shiftOf(s.role, s.role.event);
if (!reminderDue(s, shift, now)) continue;
// Claim it first so a second backend process doesn't send it again.
const claimed = await db.volunteerSlot.updateMany({ where: { id: s.id, remindedAt: null }, data: { remindedAt: new Date(now) } });
if (!claimed.count) continue;
const ev = s.role.event;
const place = ev.location ? ` at ${ev.location}` : '';
await tell(s.user, {
subject: `Reminder: serving ${s.role.name} — ${ev.title}`,
heading: 'See you soon',
text: `A reminder that you're serving: ${placeText(s.role, ev)}${place}. When you arrive, scan the volunteer check-in QR code with your phone. Can't make it? Ask for cover in My account → Serving (${servingUrl()}).`,
});
sent += 1;
}
return { sent };
}
// ─── Privacy ──────────────────────────────────────────────────────────────────
/** For "Download my data". */
async function exportVolunteering(userId, db = prisma) {
const slots = await db.volunteerSlot.findMany({
where: { userId },
include: { role: { include: { session: true, event: { select: { title: true, startDate: true, endDate: true, sessions: true } } } } },
orderBy: { createdAt: 'asc' },
});
return slots.map(s => {
const shift = shiftOf(s.role, s.role.event);
return { event: s.role.event.title, role: s.role.name, from: shift.startsAt, to: shift.endsAt, signedUp: s.createdAt, assigned: !!s.assignedById, checkedIn: s.checkedInAt };
});
}
/**
* For anonymiseUser: shifts still to come are given up (open places, free tickets released);
* past ones stay for the hours-served totals, under the anonymised account. Swap notes go.
*/
async function forgetVolunteer(tx, userId, now = new Date()) {
const slots = await tx.volunteerSlot.findMany({ where: { userId, checkedInAt: null }, include: { role: { include: { session: true, event: { select: { startDate: true, endDate: true, sessions: true } } } } } });
let removed = 0;
for (const s of slots) {
if (shiftOf(s.role, s.role.event).endsAt <= new Date(now)) continue;
await tx.volunteerSwap.updateMany({ where: { slotId: s.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date(now) } });
await releasePlace(tx, s);
await tx.volunteerSlot.delete({ where: { id: s.id } });
removed += 1;
}
await tx.volunteerSwap.updateMany({ where: { OR: [{ fromUserId: userId }, { toUserId: userId }], note: { not: null } }, data: { note: null } });
return removed;
}
module.exports = {
QR_PREFIX, MAX_NEEDED, volunteerError,
shiftOf, overlaps, shiftHours, fillOf, checkInOpen, reminderDue, kidsOccasion, reservedOptionIds, publicOptions, shiftText,
loadEvent, roleView, roster, createRole, updateRole, deleteRole, getSettings, saveSettings, checkInQr,
givePlace, releasePlace, signUp, leave, requestSwap, cancelSwap, acceptSwap, openSwaps,
checkInWithQr, checkInManual, rosteredForRooms, mySlots, eventsWithRoles, hoursServed,
signedUpMessages, sendSwapRequest, sendSwapAccepted, sendLeft, sendRoleDeleted, runReminderJob,
exportVolunteering, forgetVolunteer,
};
+31
View File
@@ -0,0 +1,31 @@
jest.mock('../src/config/db', () => ({}));
const { costCopies, setsToCopy } = require('../src/utils/eventClone');
const { isBlocked } = require('../src/hub/readOnly');
describe('event cloning', () => {
test('costs move to the new option with the same name, else to the whole event', () => {
const costs = [
{ id: 'c1', label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null, eventOption: null },
{ id: 'c2', label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'old-a', eventOption: { name: 'Adult ' } },
{ id: 'c3', label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: 'old-b', eventOption: { name: 'Leader' } },
];
const out = costCopies(costs, [{ id: 'new-a', name: 'adult' }, { id: 'new-c', name: 'Child' }]);
expect(out).toEqual([
{ label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null },
{ label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'new-a' },
{ label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: null },
]);
// Nothing of the source rows' identity is carried over.
for (const c of out) expect(c.id).toBeUndefined();
});
test('kids rooms are not copied', () => {
const sets = [{ id: 'rooms' }, { id: 'kids' }, { id: 'buses' }];
expect(setsToCopy(sets, 'kids').map(s => s.id)).toEqual(['rooms', 'buses']);
expect(setsToCopy(sets, null)).toHaveLength(3);
});
test('copying setup is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/events/abc/copy-from')).toBe(true);
});
});
+48 -1
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
const { compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
@@ -100,6 +100,8 @@ describe('read-only mode', () => {
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/hub/update-request/cancel'],
['POST', '/api/hub/update-snooze'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
@@ -151,3 +153,48 @@ test('an update request shows until the site runs the version asked for', () =>
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
});
describe("what's new", () => {
const releases = [
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
{ version: '1.48.0', notes: '- Webhooks', urgent: false, releasedAt: '2026-10-30T10:00:00.000Z' },
{ version: '1.47.1', notes: '- Security fix', urgent: true, releasedAt: '2026-10-29T10:00:00.000Z' },
{ version: 'bogus', notes: 'x' },
];
test('only versions newer than this site, newest first', () => {
expect(newerReleases(releases, '1.47.0').map(r => r.version)).toEqual(['1.48.0', '1.47.1']);
expect(newerReleases(releases, '1.48.0')).toEqual([]);
expect(newerReleases(null, '1.0.0')).toEqual([]);
});
test('"Not now" holds until a newer version than the one put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: '1.47.0', urgent: false })).toBe(true);
expect(updateSnoozed({ latestVersion: '1.48.0', snoozedVersion: '1.47.0', urgent: false })).toBe(false);
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: null, urgent: false })).toBe(false);
});
test('urgent updates can\'t be put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.1', snoozedVersion: '1.47.1', urgent: true })).toBe(false);
});
});
describe("the provider's notices", () => {
const NOW_N = new Date('2026-11-01T18:30:00Z');
const n = (id, extra = {}) => ({ id, level: 'info', title: 'T', message: 'M', startsAt: null, endsAt: null, ...extra });
test('show between their start and end', () => {
const notices = [
n('now'),
n('window', { startsAt: '2026-11-01T18:00:00Z', endsAt: '2026-11-01T20:00:00Z' }),
n('later', { startsAt: '2026-11-02T18:00:00Z' }),
n('ended', { endsAt: '2026-11-01T18:00:00Z' }),
];
expect(activeNotices(notices, NOW_N).map(x => x.id)).toEqual(['now', 'window']);
});
test('an unknown level shows as information', () => {
expect(activeNotices([n('x', { level: 'party' })], NOW_N)[0].level).toBe('info');
expect(activeNotices([n('x', { level: 'maintenance' })], NOW_N)[0].level).toBe('maintenance');
});
});
+151
View File
@@ -0,0 +1,151 @@
const kids = require('../src/utils/kids');
describe('pickup codes', () => {
test('are 4 characters from the alphabet without 0/O/1/I', () => {
for (let i = 0; i < 500; i++) {
const c = kids.randomCode();
expect(c).toHaveLength(4);
expect(c).toMatch(/^[A-HJ-NP-Z2-9]{4}$/);
}
});
test('typed or scanned codes are cleaned up', () => {
expect(kids.normaliseCode('k7p2')).toBe('K7P2');
expect(kids.normaliseCode(' K7 P2 ')).toBe('K7P2');
expect(kids.normaliseCode('KIDS-K7P2')).toBe('K7P2');
expect(kids.normaliseCode('k7-p2')).toBe('K7P2');
});
test('anything that cannot be a code is null', () => {
expect(kids.normaliseCode('')).toBeNull();
expect(kids.normaliseCode('K7P')).toBeNull();
expect(kids.normaliseCode('K7P22')).toBeNull();
expect(kids.normaliseCode('K0P2')).toBeNull(); // no zero
expect(kids.normaliseCode('KOP2')).toBeNull(); // no O
expect(kids.normaliseCode('K1P2')).toBeNull();
});
});
describe('ages and rooms', () => {
const on = new Date('2026-11-01T08:00:00Z');
test('age in whole years, birthday counted on the day', () => {
expect(kids.ageOn('2021-11-01', on)).toBe(5);
expect(kids.ageOn('2021-11-02', on)).toBe(4);
expect(kids.ageOn(null, on)).toBeNull();
});
const rooms = [
{ id: 'all', name: 'Everyone', minAge: null, maxAge: null, order: 0 },
{ id: 'tod', name: 'Toddlers', minAge: 1, maxAge: 3, order: 1, capacity: 2 },
{ id: 'jun', name: 'Juniors', minAge: 4, maxAge: 7, order: 2 },
];
test('fitsRoom respects the range; no age fits anywhere', () => {
expect(kids.fitsRoom(rooms[1], 2)).toBe(true);
expect(kids.fitsRoom(rooms[1], 4)).toBe(false);
expect(kids.fitsRoom(rooms[1], null)).toBe(true);
});
test('suggests the narrowest room that fits', () => {
expect(kids.suggestRoom(rooms, 2).id).toBe('tod');
expect(kids.suggestRoom(rooms, 5).id).toBe('jun');
expect(kids.suggestRoom(rooms, 11).id).toBe('all');
});
test('skips full rooms', () => {
expect(kids.suggestRoom(rooms, 2, { tod: 2 }).id).toBe('all');
});
test('without an age, the first room in order', () => {
expect(kids.suggestRoom(rooms, null).id).toBe('all');
});
test('nothing fits → null', () => {
expect(kids.suggestRoom([rooms[1]], 9)).toBeNull();
});
});
describe('room status', () => {
test('ratio: leaders needed and over ratio', () => {
expect(kids.roomStatus({ ratio: 5 }, 6, 1)).toMatchObject({ leadersNeeded: 2, overRatio: true });
expect(kids.roomStatus({ ratio: 5 }, 5, 1)).toMatchObject({ leadersNeeded: 1, overRatio: false });
expect(kids.roomStatus({ ratio: 5 }, 0, 0)).toMatchObject({ leadersNeeded: 0, overRatio: false });
});
test('no ratio is never over ratio; capacity makes it full', () => {
expect(kids.roomStatus({ capacity: 3 }, 3, 0)).toMatchObject({ full: true, overRatio: false });
expect(kids.roomStatus({}, 30, 0)).toMatchObject({ full: false, overRatio: false });
});
});
describe('sticker text', () => {
test('allergy and medical flags, kept short', () => {
expect(kids.stickerAlert('Peanuts', '')).toBe('ALLERGY: PEANUTS');
expect(kids.stickerAlert('', 'Asthma pump in bag')).toBe('MEDICAL');
expect(kids.stickerAlert('nuts', 'epipen')).toBe('ALLERGY: NUTS · MEDICAL');
expect(kids.stickerAlert('', '')).toBe('');
expect(kids.stickerAlert('a'.repeat(40), '')).toMatch(/^ALLERGY: A{24}…$/);
});
});
describe('occasions', () => {
const single = { scheduleType: 'single', startDate: '2026-11-01T07:00:00Z', endDate: '2026-11-01T10:00:00Z' };
test('a single-date event: its day, only while it is on', () => {
expect(kids.occasionFor(single, [], null, new Date('2026-11-01T06:00:00Z'))).toEqual({ occasion: '2026-11-01', sessionId: null });
expect(() => kids.occasionFor(single, [], null, new Date('2026-11-02T08:00:00Z'))).toThrow(/open on 2026-11-01/);
});
test('a multi-session event: today\'s session', () => {
const ev = { scheduleType: 'sessions', checkInWindow: 'whole_day' };
const sessions = [
{ id: 's1', startsAt: '2026-11-01T07:00:00Z', endsAt: '2026-11-01T09:00:00Z' },
{ id: 's2', startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' },
];
expect(kids.occasionFor(ev, sessions, null, new Date('2026-11-08T06:30:00Z'))).toEqual({ occasion: 's2', sessionId: 's2' });
expect(() => kids.occasionFor(ev, sessions, null, new Date('2026-11-04T08:00:00Z'))).toThrow(/next one/);
});
});
describe('messages', () => {
test('check-in message has the code and rooms, nothing medical', () => {
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia', room: 'Toddlers' }, { name: 'Ben', room: 'Juniors' }] });
expect(text).toContain('Mia and Ben are checked in at Sunday Kids (Toddlers, Juniors)');
expect(text).toContain('Pickup code: K7P2');
expect(text).toContain('/dashboard/user/kids');
});
test('for an authorised adult, no link to an account', () => {
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia' }], forParent: false });
expect(text).toContain('Mia is checked in');
expect(text).not.toContain('My account');
});
test('listNames', () => {
expect(kids.listNames(['A'])).toBe('A');
expect(kids.listNames(['A', 'B', 'C'])).toBe('A, B and C');
});
});
describe('children', () => {
test('cleanChild needs a first name and seals the notes', () => {
expect(() => kids.cleanChild({})).toThrow(/first name/);
const data = kids.cleanChild({ firstName: ' Mia ', allergies: 'Peanuts', custodyNotes: 'Not to Mr X' });
expect(data.firstName).toBe('Mia');
expect(data.allergies).toMatch(/^enc:/);
expect(data.custodyNotes).toMatch(/^enc:/);
expect(kids.childView({ id: 'c', ...data }).allergies).toBe('Peanuts');
});
test('a future date of birth is refused', () => {
expect(() => kids.cleanChild({ firstName: 'A', dateOfBirth: '2999-01-01' })).toThrow(/date of birth/);
});
test('custodyChanged only when the text changes', () => {
const existing = kids.cleanChild({ firstName: 'A', custodyNotes: 'x' });
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'x' }, existing))).toBe(false);
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'y' }, existing))).toBe(true);
expect(kids.custodyChanged(existing, kids.cleanChild({ allergies: 'y' }, existing))).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
const v = require('../src/utils/volunteers');
const { isBlocked } = require('../src/hub/readOnly');
const event = {
id: 'e1', startDate: new Date('2026-11-08T06:00:00Z'), endDate: new Date('2026-11-08T10:00:00Z'),
sessions: [{ id: 's1', startsAt: new Date('2026-11-15T16:30:00Z'), endsAt: new Date('2026-11-15T18:30:00Z') }],
};
describe('shifts', () => {
test("a role's shift: its own times, else its session's, else the event's", () => {
expect(v.shiftOf({}, event)).toEqual({ startsAt: event.startDate, endsAt: event.endDate });
expect(v.shiftOf({ sessionId: 's1' }, event)).toEqual({ startsAt: event.sessions[0].startsAt, endsAt: event.sessions[0].endsAt });
const own = { startsAt: '2026-11-08T05:30:00Z', endsAt: '2026-11-08T07:00:00Z' };
expect(v.shiftOf(own, event)).toEqual({ startsAt: new Date(own.startsAt), endsAt: new Date(own.endsAt) });
});
test('overlapping shifts; touching ends are fine', () => {
const a = { startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:00:00Z' };
expect(v.overlaps(a, { startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(true);
expect(v.overlaps(a, { startsAt: '2026-11-08T08:00:00Z', endsAt: '2026-11-08T09:00:00Z' })).toBe(false);
expect(v.overlaps(a, { startsAt: '2026-11-08T05:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(false);
});
test('hours to the nearest quarter hour', () => {
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T08:30:00Z' })).toBe(2.5);
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:50:00Z' })).toBe(0.75);
expect(v.shiftHours({ startsAt: '2026-11-08T06:00:00Z', endsAt: '2026-11-08T06:00:00Z' })).toBe(0);
});
test('fill', () => {
expect(v.fillOf(4, 3)).toEqual({ needed: 4, filled: 3, open: 1, full: false });
expect(v.fillOf(2, 3)).toEqual({ needed: 2, filled: 3, open: 0, full: true });
});
});
describe('check-in window', () => {
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
test('open on the day of the shift until it ends', () => {
expect(v.checkInOpen(shift, new Date('2026-11-08T04:00:00Z'))).toBe(true); // early, same day (SAST)
expect(v.checkInOpen(shift, new Date('2026-11-08T07:59:00Z'))).toBe(true);
});
test('closed the day before and after the shift ends', () => {
expect(v.checkInOpen(shift, new Date('2026-11-07T12:00:00Z'))).toBe(false);
expect(v.checkInOpen(shift, new Date('2026-11-08T08:01:00Z'))).toBe(false);
});
});
describe('reminders', () => {
const shift = { startsAt: new Date('2026-11-08T06:00:00Z'), endsAt: new Date('2026-11-08T08:00:00Z') };
const signedUp = new Date('2026-11-01T10:00:00Z');
test('due within 24 hours of the start, once', () => {
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(true);
expect(v.reminderDue({ remindedAt: new Date(), createdAt: signedUp }, shift, new Date('2026-11-07T07:00:00Z'))).toBe(false);
});
test('not too early, not after the start', () => {
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-07T05:00:00Z'))).toBe(false);
expect(v.reminderDue({ remindedAt: null, createdAt: signedUp }, shift, new Date('2026-11-08T06:00:00Z'))).toBe(false);
});
test('not for someone who signed up minutes ago', () => {
const now = new Date('2026-11-07T20:00:00Z');
expect(v.reminderDue({ remindedAt: null, createdAt: new Date(now.getTime() - 10 * 60 * 1000) }, shift, now)).toBe(false);
});
});
describe('kids rooms', () => {
test('a session role counts for its session; otherwise for the day', () => {
expect(v.kidsOccasion({ sessionId: 's1' })).toBe('s1');
expect(v.kidsOccasion({ sessionId: null }, new Date('2026-11-08T06:00:00Z'))).toBe('2026-11-08');
});
});
describe('reserved tickets', () => {
test('the kids and volunteer tickets never show publicly', () => {
const ev = { kidsOptionId: 'k', volunteerOptionId: 'vol', eventOptions: [{ id: 'adult' }, { id: 'k' }, { id: 'vol' }] };
expect(v.reservedOptionIds(ev)).toEqual(['k', 'vol']);
expect(v.publicOptions(ev).map(o => o.id)).toEqual(['adult']);
expect(v.publicOptions({ eventOptions: [{ id: 'a' }] }).map(o => o.id)).toEqual(['a']);
});
});
describe('read-only licence', () => {
test.each([
['POST', '/api/volunteers/roles/r1/sign-up'],
['POST', '/api/volunteers/manage/events/e1/roles'],
['POST', '/api/volunteers/manage/roles/r1/assign'],
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
test.each([
['POST', '/api/volunteers/check-in'],
['POST', '/api/volunteers/swaps/w1/accept'],
['DELETE', '/api/volunteers/slots/s1'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
});
+88 -14
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-27, after **1.44.0** (and Hub 1.1.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.0** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,12 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
| 1.43.0 | **Campuses** (§12 part 1): Site settings → Campuses (off by default), events per campus or all campuses, public campus filter (remembered, starts on home campus) and `/campus/<slug>` pages, home campus on the profile, campus filters on Reports and the dashboards, "Everyone at a campus" in broadcasts, campus summary recipients, campus on ticket PDFs, heartbeat `campusCount`. Also adds the `hub_update_requested` audit action missing since 1.42.0 |
| 1.42.1 | Production Hub public key built in (`publicKey.js`); `HUB_PUBLIC_KEY` only needed for a local Hub |
@@ -39,22 +45,25 @@ encrypted settings unreadable or break existing installs.
## Next up
1. **Roll out the update requests:** deploy **Hub 1.1.0**, then set "Latest Cross Events release"
to `1.42.0` in Hub → Settings, then update the sites to 1.42.0 (the last update done without a
request). A 1.42.0 site talking to an older Hub shows "The Hub doesn't take update requests yet".
2. **Try `scripts/install.sh` on a test server.** The production Hub key is built in since
1.42.1, so a fresh install needs no `HUB_PUBLIC_KEY`; existing installs can drop it from `.env`.
3. **1.45.0 — Kids check-in** (§4), then 1.46.0 volunteer rosters (§5) — both per campus.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **Event series** (§6), one patch per part: **1.47.1 series** (can span campuses), **1.47.2
year-on-year reports**, **1.47.3 season pass** — then **1.48.0 — outbound webhooks** (§8).
## Working on this repo
- **Release routine:** branch `feat/<name>` → commit → bump with
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → write the church-facing
`## X.Y.Z` section in **WHATS-NEW.md** (plain language; `— urgent` in the heading for a security
or serious fix) → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
@@ -97,9 +106,9 @@ encrypted settings unreadable or break existing installs.
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
people only.
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
- No CI: the Gitea workflow was removed (no machine to run a Docker runner). Run the
backend tests, frontend eslint and e2e tests yourself before tagging, and deploy by
hand with `scripts/deploy.sh <tag>` on the server.
## Where the multi-day code lives
@@ -190,8 +199,7 @@ encrypted settings unreadable or break existing installs.
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
run on a real server).
- `scripts/install.sh` — fresh Ubuntu server to a running site (tried on a test server 2026-10-28).
## Where campuses live (1.43.0)
@@ -230,6 +238,65 @@ encrypted settings unreadable or break existing installs.
the scanner (403 naming the campus). Tested in `tests/campusScope.test.js` and e2e
`campus-scoped.spec.ts` (staff and supervisor scoped to E2E South; a seeded North ticket).
## Where kids check-in lives (1.45.0)
- `backend/src/utils/kids.js` — everything: codes (`randomCode`, `normaliseCode`), occasions
(`occasionFor`: today's session, or the day), room suggestion and status, families and children
(allergies/medical/custody encrypted with `utils/encryption.js`), `checkIn` (code per family per
occasion via `KidsPickupCode`, a free place on `Event.kidsOptionId` through `ticketForChild`, a
`TicketUsage` per check-in), `checkOut` / `overrideCheckOut`, messages, `board`, duty, offline
snapshot and `syncOffline`, and the privacy parts (`removeFromFamily`, `exportFamily`,
`runKidsRetention`). Pure parts tested in `tests/kids.test.js`.
- `controllers/kidsController.js` + `routes/kidsRoutes.js` (`/api/kids`); campus resolvers
`kidsCheckIn` and `kidsAlert` in `utils/campusScope.js`.
- Rooms are `AllocationGroup`s of `Event.kidsRoomSetId` (with `minAge`, `maxAge`, `ratio`).
Event ids on `Event` (`kidsOptionId`, `kidsRoomSetId`) are plain strings, checked in code.
- Kids check-in isn't blocked in licence read-only mode, on purpose: it's like scanning tickets,
and children's safety at pickup comes first.
- Frontend: `lib/kids.ts` (types, room suggestion, offline copy in `hope_events_kids_offline_<eventId>`),
`components/kids/*` (`KidsEventPicker`, `ChildForm`, `RoomBoard`, `StickerPanel`), pages
`app/dashboard/staff/kids` (desk), `staff/kids/rooms`, `supervisor/kids` (setup), `user/kids`.
- e2e: `kids.spec.ts` on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk
checks in, parent sees the code, wrong code then the right one, room alert).
## Where volunteer rosters live (1.46.0)
- `backend/src/utils/volunteers.js` — everything: shifts (`shiftOf`: the role's own times, else its
session's, else the event's), overlap and check-in-window rules, roles, sign-up / leave / swaps,
the free volunteer place (`givePlace` / `releasePlace`, `VolunteerSlot.registrationId`), QR and
manual check-in (kids-room roles start a `KidsRoomDuty` with `fromRoster`), `rosteredForRooms`
(used by the kids board), messages, the `volunteers` reminder job, hours served, and the privacy
parts (`exportVolunteering`, `forgetVolunteer`). Pure parts tested in `tests/volunteers.test.js`.
- `reservedOptionIds` / `publicOptions` there: the kids and volunteer tickets are left out of public
event pages and refused on public registration (`eventController`, `registrationController`).
**A new staff-only ticket type should be added to `reservedOptionIds`.**
- `controllers/volunteerController.js` + `routes/volunteerRoutes.js` (`/api/volunteers`); campus
resolvers `servingRole` and `volunteerSlot`; sign-ups, new roles and assigning are in `readOnly.js`.
- Swaps: no approval — accepting moves the slot at once (claimed with an `updateMany` on
`status: 'open'` so two people can't both take it); `tellOrganisers` emails
`getEventNotifyEmails(event)` (notify recipients, else the creator), not the registrations inbox.
- The check-in QR holds `SERVE-<Event.volunteerCheckInToken>`; the page encodes it as a URL
(`/serve/check-in?code=…`) so phone cameras open it directly.
- Frontend: `lib/volunteers.ts`, `components/volunteers/{RosterSheet,CheckInQr}.tsx`, pages
`dashboard/supervisor/volunteers` (roles, QR, ticket, hours), `dashboard/staff/volunteers` (the day),
`dashboard/user/serving` (`?event=<id>` opens one event — the shareable sign-up link), `serve/check-in`.
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
sign-up, cover request, staff take over, QR check-in, roster).
## Where copying an event lives (1.47.0)
- The copy is made in the browser: `EventModal` in `app/dashboard/supervisor/events/page.tsx` takes
`source` (create mode, `cloning`), fills its state from it (`cloneOptions` leaves out the kids and
volunteer tickets and all ids; cancelled sessions dropped; sessions keyed by the source's ids so
late joining still points right; sections loaded and mapped onto the copied options; feedback
questions without the sent/answered state), and `moveAllDates` shifts every date for "New start".
It saves through the normal create path, so **a new field on the event form is copied
automatically** if it's initialised from `base`.
- What the form doesn't hold is copied by the server afterwards: `utils/eventClone.js`
(`copyEventSetup`: allocation sets/groups except `kidsRoomSetId`, costs for admins only since
costs are admin-only) via `POST /api/events/:id/copy-from`. Tests: `tests/eventClone.test.js`,
e2e "copy an event" in `supervisor.spec.ts`.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
@@ -248,3 +315,10 @@ encrypted settings unreadable or break existing installs.
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
- Kids check-in: the offline desk is tested at the API level (sync, idempotency, code
reservation), not in the browser. A family's two parents share one family; linking a second
parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.
- Volunteer rosters: if a volunteer edits their free volunteer registration on the edit page, the
volunteer ticket isn't among the public options, so saving drops it (the backend only refuses
adding or increasing it). Rare; the place is still in the roster, and a supervisor can give a
ticket at the door. Reminders are a single "day before" one (no per-event timing yet).
+67 -38
View File
@@ -30,7 +30,12 @@ Cross Events Hub); 1.42.0 **Request update** button instead of the deploy comman
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
**Next: 1.45.0 Kids check-in.**
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** shipped.
**Next: 1.47.1 Event series.**
**Repositories:**
@@ -46,10 +51,10 @@ per campus, public filter and campus pages, home campus, filters, campus broadca
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
4. [Kids check-in (digital pickup codes)](#4-kids-check-in-digital-pickup-codes)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
6. [Event series and cloning](#6-event-series-and-cloning)
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
@@ -393,43 +398,55 @@ self-service privacy tools.
---
## 4. Kids check-in with security labels
## 4. Kids check-in (digital pickup codes)
A secure check-in/check-out flow for children's ministry and youth events.
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
A secure check-in/check-out flow for children's ministry, run from a **staff desk**.
**Builds on sessions (§1), attendees, allocation groups and campuses (§12).** Fully digital —
no label printers (2026-10-28).
**Who:** Parents check in children (self-service kiosk or staff desk).
Kids-ministry staff run rooms. Supervisors set up rooms.
**Who:** Kids-ministry staff at the check-in desk and in the rooms. Supervisors set up rooms.
Parents manage their family's children in My account and see pickup codes on their phone.
### How it works
- Parent finds their family (phone number / QR from My account) and ticks the
children being dropped off. New children can be added on the spot.
- The system prints **two labels**:
- **Child label:** name, room, allergy/medical icons, short pickup code.
- **Parent label:** the same pickup code.
- **Check-out** requires scanning or typing the matching code. A mismatch
alerts the room leader.
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
1 leader per 5 toddlers) and live room counts.
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
parent ("Please come to the Toddlers room").
- Attendance per child per session feeds the attendance register.
- **Families and children are kept between events** (weekly kids church): a family has one or
more parent accounts, its children (name, date of birth, allergies, medical notes, custody
notes — the last three encrypted) and **authorised adults** (name, phone, relationship — no
account needed). Parents manage them under My account → Kids; staff can find a family by
phone, name or the parent's QR code and add a family or child on the spot.
- An event gets a **Kids check-in** switch (single events or one session per Sunday). The first
check-in makes a free registration under the parent with an attendee and ticket per child, and
each check-in writes a `TicketUsage`, so the attendance register, reports and campus scoping work
as they do for tickets.
- Each check-in gets a **pickup code** (4 characters, no 0/O/1/I, unique per event and day). The
parent sees it live on their phone (code + QR) and gets it by WhatsApp/email; it can also be
sent to an authorised adult. **Anyone with the code can collect** the child.
- **"Write on the sticker" panel** on the desk screen: first name, room, code and a short alert
(ALLERGY / MEDICAL) in large print, for volunteers to copy onto a blank sticker. The full
details stay on screen.
- **Rooms** are a "Kids rooms" allocation set: each room has an age range, capacity and a
**ratio** (children per leader). The room is suggested from the child's age. Room screens show
live cards (name, code, allergy/medical icons, custody notes) and counts: children in, leaders
on duty ("I'm on duty"), over-ratio and full warnings.
- **Check-out** by scanning the parent's QR or typing the code; the leader records who collected.
A wrong code alerts the room and is logged; "Override" needs a reason.
- **Page a parent:** "Call parent" → WhatsApp (email fallback) and a notice on the parent's page.
### Plumbing / edge cases
- Label printing via browser print with label-size CSS (Dymo / Brother label
printers, 62 mm and 89×36 mm presets); PDF fallback.
- Allergies and medical info come from the consent/medical fields; respect
the medical-info deletion rules.
- Custody notes ("Do not release to …") visible to room leaders only.
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
- Medical and custody notes: room leaders, supervisors and admins only; never in messages;
room lists showing them are written to the access log.
- POPIA: families and children in `anonymiseUser` / `exportUserData`; retention rule — a child's
profile is deleted after **6 months** without a check-in; readiness flags children's data.
- Offline: the desk caches the event's families and checks in offline (device-prefixed codes,
synced later). Check-out offline only for children checked in on the same device.
- Audit log: overrides, wrong codes and custody-note changes.
---
## 5. Volunteer / serving rosters
Plan who serves where, for an event or each session.
Plan who serves where, for an event or each session. (Shipped in 1.46.0 — decisions below.)
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
with the number of people needed.
@@ -444,20 +461,28 @@ Plan who serves where, for an event or each session.
## 6. Event series and cloning
- **Clone event:** copy an event with its options, variants, early-bird tiers,
forms, consent text, feedback form, cost template, allocation groups and
sessions (dates shifted to the new start). No registrations.
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
Four releases, one per part (2026-10-28): **1.47.0** clone, **1.47.1** series, **1.47.2**
year-on-year reports, **1.47.3** season pass.
- **Clone event (1.47.0 ✅):** "Copy" opens the **New event popup filled in** from the source, so
everything can be changed before saving: options, variants, early-bird tiers, form, consent
text, feedback form, sections, notification recipients, certificates, reminders, allocation
groups, cost template (admins) and sessions. A "New start" moves every date with it. Of the
newer features only the **campus** is copied — not kids check-in or volunteer roles. No
registrations, **no coupons**.
- **Series (1.47.1):** group separate events (Camp 2025, Camp 2026; monthly Men's
Breakfast) on one series page, with "Next in this series" and a series
follow/notify option.
- **Year-on-year reports:** registrations, income and attendance compared
follow/notify option. **A series can span several campuses.**
- **Year-on-year reports (1.47.2):** registrations, income and attendance compared
across events in a series.
- **Season pass:** a bundle across events in a series (extends event bundles).
- **Season pass (1.47.3):** a bundle across events in a series (extends event bundles).
---
## 7. Apple and Google Wallet tickets
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
ticket, in email and in My Events.
- Pass shows event, date (next session for multi-day), location, holder name,
@@ -934,10 +959,10 @@ income justify the hardware:
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 – 1.46.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 – 1.49.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.50.0 – 1.52.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.53.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -987,6 +1012,10 @@ feature.
| — | Campus-scoped staff (1.44.0) | Scoped staff: their campuses' events plus "All campuses" events, which they can view, scan, take door payments and do manual registrations for, but not set up, cash up or refund. Banner, provider reconciliation and unlinked donations: unscoped only; broadcasts limited to their campuses' audiences and their events' attendees; coupons only for their events. Other campuses' records answer "not found" except the scanner, which names the campus. Scopes ignored while campuses are off, with a warning (2026-10-27) |
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses (2026-10-28) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
## Still open
+41 -2
View File
@@ -55,7 +55,9 @@ POST /api/installs/register
"installSecret": "…", // shown once; the install stores it encrypted
"licence": "<licence token>",
"heartbeatIntervalMinutes": 15,
"latestVersion": "1.41.0"
"latestVersion": "1.41.0",
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
"notices": [ … ]
}
```
@@ -101,9 +103,30 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
}
}
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
→ 200 {
"licence": "<fresh licence token>",
"latestVersion": "1.42.0",
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
],
"notices": [ // Hub 1.4.0+: the provider's banners for this install
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
],
"serverTime": "…"
}
```
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
admins put them off. The highest release is `latestVersion`.
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
@@ -132,6 +155,22 @@ X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
### Withdrawing it
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
```
POST /api/installs/update-request/cancel
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1 }
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
→ 200 { "cancelled": false } // there was no open request
```
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
## 4. Licence token
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string };
campuses: Record<"north" | "south", { id: string; slug: string }>;
northTicketQr: string;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, seed, loginAs } from "./fixtures";
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
// the real one checks the child out. "E2E Kids Church" is seeded hidden and on today.
test.describe.serial("kids check-in", () => {
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/kids");
await page.getByRole("combobox").first().selectOption(seed().events.kids);
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
await page.getByLabel("Room name").last().fill("Little Lambs");
await page.getByLabel("Youngest age").last().fill("0");
await page.getByLabel("Oldest age").last().fill("5");
await page.getByLabel("Children per leader").last().fill("4");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByLabel("Room name").first()).toHaveValue("Little Lambs");
await page.getByRole("button", { name: "Switch on" }).click();
await expect(page.getByText("Kids check-in is on")).toBeVisible();
});
test("a parent adds their family and a child", async ({ page }) => {
await loginAs(page, "user");
await page.goto("/dashboard/user/kids");
await page.getByRole("button", { name: "Add family" }).click();
await page.getByRole("button", { name: "Add a child" }).click();
await page.getByLabel("First name *").fill("Lulu");
const dob = new Date();
dob.setFullYear(dob.getFullYear() - 3);
await page.getByLabel("Date of birth").fill(dob.toISOString().slice(0, 10));
await page.getByLabel("Allergies").fill("Peanuts");
await page.getByRole("button", { name: "Add child" }).click();
await expect(page.getByText("Allergies: Peanuts")).toBeVisible();
await expect(page.getByAltText("Family QR code")).toBeVisible();
});
test("the desk checks the child in, the parent sees the code, and only the right code checks out", async ({ page, browser }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/kids");
await page.getByLabel("Event").selectOption(seed().events.kids);
await page.getByLabel("Find a family").fill("Lulu");
await page.getByRole("button", { name: "Search" }).click();
await page.getByRole("button", { name: /Lulu/ }).click();
// The room is suggested from her age.
await expect(page.getByLabel("Room for Lulu")).toHaveValue(/.+/);
await page.getByRole("button", { name: "Check in", exact: true }).last().click();
const code = (await page.getByTestId("desk-code").textContent())!.trim();
expect(code).toMatch(/^[A-HJ-NP-Z2-9]{4}$/);
const sticker = page.getByTestId("sticker");
await expect(sticker).toContainText("Lulu");
await expect(sticker).toContainText("Little Lambs");
await expect(sticker).toContainText("ALLERGY: PEANUTS");
// The parent's phone shows the same code.
const parent = await browser.newPage();
await parent.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
await loginAs(parent, "user");
await parent.goto("/dashboard/user/kids");
await expect(parent.getByTestId("pickup-code")).toHaveText(code);
await parent.close();
// A wrong code is refused.
await page.getByRole("button", { name: "Check out" }).first().click();
const wrong = code === "ZZZZ" ? "YYYY" : "ZZZZ";
await page.getByLabel("Pickup code").fill(wrong);
await page.getByRole("button", { name: "Find" }).click();
await expect(page.getByRole("alert").filter({ hasText: "doesn't match any child" })).toBeVisible();
// The real one checks her out.
await page.getByLabel("Pickup code").fill(code.toLowerCase());
await page.getByRole("button", { name: "Find" }).click();
await expect(page.getByText("Lulu", { exact: false }).first()).toBeVisible();
await page.getByRole("button", { name: "Check out" }).last().click();
await expect(page.getByRole("status")).toContainText("Collected: Lulu");
});
test("the room screen shows the wrong-code alert", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/kids/rooms");
await expect(page.getByRole("alert").filter({ hasText: "Wrong pickup code" }).first()).toBeVisible();
await page.getByRole("button", { name: "Dealt with" }).first().click();
await expect(page.getByRole("status")).toContainText("Alert cleared");
});
});
+42
View File
@@ -32,6 +32,48 @@ test.describe("supervisor tools", () => {
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
});
test("copy an event: the popup is filled in and every date moves with the new start", async ({ page }) => {
type Full = EventRow & { eventOptions: { name: string; price: number }[]; sessions: { startsAt: string; label?: string | null }[] };
const source = await api<Full>(page, "supervisor", "GET", `/api/events/${seed().events.course}`);
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/events");
await page.locator("li", { hasText: "E2E Alpha Course" }).first().getByRole("button", { name: "Copy" }).click();
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeVisible();
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Alpha Course");
await page.getByPlaceholder("Event title").fill("E2E Alpha Course (next term)");
// A week later: the start box holds the first session, so move it by seven days.
const start = page.getByTestId("copy-panel").locator('input[type="datetime-local"]');
const current = await start.inputValue();
const later = new Date(new Date(current).getTime() + 7 * 24 * 60 * 60 * 1000);
const pad = (n: number) => String(n).padStart(2, "0");
await start.fill(`${later.getFullYear()}-${pad(later.getMonth() + 1)}-${pad(later.getDate())}T${pad(later.getHours())}:${pad(later.getMinutes())}`);
const create = page.getByRole("button", { name: "Create Event" });
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
await page.getByRole("button", { name: "Next →" }).click();
}
await create.click();
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeHidden();
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all?includePast=true");
const row = all.find((e) => e.title === "E2E Alpha Course (next term)");
expect(row).toBeTruthy();
const copy = await api<Full>(page, "supervisor", "GET", `/api/events/${row!.id}`);
expect(copy.scheduleType).toBe("sessions");
const active = (s: Full) => s.sessions.filter((x) => !(x as { cancelled?: boolean }).cancelled);
expect(active(copy)).toHaveLength(active(source).length);
const week = 7 * 24 * 60 * 60 * 1000;
// The form works in whole minutes (the seeded times have seconds).
const minute = (iso: string) => Math.floor(new Date(iso).getTime() / 60000) * 60000;
active(copy).forEach((s, i) => {
expect(minute(s.startsAt) - minute(active(source)[i].startsAt)).toBe(week);
expect(s.label ?? null).toBe(active(source)[i].label ?? null);
});
expect(copy.eventOptions.map((o) => [o.name, o.price]).sort()).toEqual(source.eventOptions.map((o) => [o.name, o.price]).sort());
});
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
+16 -1
View File
@@ -81,6 +81,21 @@ async function seed(url) {
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
// Kids check-in (kids.spec.ts): on today, hidden so it doesn't change the public lists.
const kids = await prisma.event.create({
data: {
title: 'E2E Kids Church', description: 'Kids check-in for the tests.', startDate: new Date(Date.now() - 60 * 60 * 1000), endDate: new Date(Date.now() + 6 * 60 * 60 * 1000),
price: 0, isHidden: true, createdById: users.supervisor.id,
},
});
// Volunteer rosters (volunteers.spec.ts): on today, hidden.
const serving = await prisma.event.create({
data: {
title: 'E2E Serving Sunday', description: 'Volunteer rosters for the tests.', startDate: new Date(Date.now() - 60 * 60 * 1000), endDate: new Date(Date.now() + 6 * 60 * 60 * 1000),
price: 0, isHidden: true, createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
const paid = await prisma.event.create({
data: {
campusId: south.id,
@@ -174,7 +189,7 @@ async function seed(url) {
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id },
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
northTicketQr: northTicket.qrCode,
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, seed, loginAs, API } from "./fixtures";
// Volunteer rosters (1.46.0): a supervisor adds a role and switches the free volunteer ticket on,
// a user signs up, can't make it and asks for cover, staff take the place over, then check in by
// scanning the event's QR code. "E2E Serving Sunday" is seeded hidden and on today.
test.describe.serial("volunteer rosters", () => {
test("a supervisor adds a role and switches the volunteer ticket on", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto(`/dashboard/supervisor/volunteers?event=${seed().events.serving}`);
await page.getByRole("button", { name: "Add a role" }).click();
await page.getByLabel("Role name").fill("Welcome team");
await page.getByLabel("People needed").fill("1");
await page.getByRole("button", { name: "Add role" }).click();
await expect(page.getByTestId("roster-role")).toContainText("Welcome team");
await expect(page.getByTestId("roster-totals")).toContainText("0 of 1");
await page.getByRole("tab", { name: "Volunteer ticket" }).click();
await page.getByRole("button", { name: /Add a free "Volunteer" ticket/ }).click();
await expect(page.getByLabel("Volunteer ticket")).not.toHaveValue("");
});
test("the volunteer ticket isn't offered publicly", async ({ request }) => {
const res = await request.get(`${API}/api/events/${seed().events.serving}`);
const ev = await res.json();
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Entry"]);
});
test("a user signs up, then asks for cover", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/dashboard/user/serving?event=${seed().events.serving}`);
await page.getByTestId("open-role").filter({ hasText: "Welcome team" }).getByRole("button", { name: "Sign up" }).click();
await expect(page.getByRole("status")).toContainText("you're on the roster");
const shift = page.getByTestId("my-shift").filter({ hasText: "Welcome team" });
await expect(shift).toContainText("free volunteer ticket");
page.once("dialog", d => d.accept("Away that weekend"));
await shift.getByRole("button", { name: "Can't make it" }).click();
await expect(shift).toContainText("Cover requested");
});
test("staff take the place over, then check in with the QR code", async ({ page, request }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/user/serving");
const cover = page.getByTestId("cover-request").filter({ hasText: "Welcome team" });
await expect(cover).toContainText("Away that weekend");
await cover.getByRole("button", { name: "Take over" }).click();
await expect(page.getByRole("status")).toContainText("taken over Welcome team");
await expect(page.getByTestId("my-shift").filter({ hasText: "Welcome team" })).toBeVisible();
// The QR code on the wall holds this; a phone camera opens the check-in page.
const qr = await request.get(`${API}/api/volunteers/manage/events/${seed().events.serving}/qr`, { headers: { Authorization: `Bearer ${seed().users.supervisor.token}` } });
const { qr: code } = await qr.json();
await page.goto(`/serve/check-in?code=${encodeURIComponent(code)}`);
await expect(page.getByTestId("volunteer-checked-in")).toContainText("Welcome team");
});
test("the roster shows who took over and checked in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto(`/dashboard/supervisor/volunteers?event=${seed().events.serving}`);
const volunteer = page.getByTestId("roster-volunteer");
await expect(volunteer).toContainText(seed().users.staff.name);
await expect(volunteer).toContainText("(QR)");
await expect(page.getByTestId("roster-totals")).toContainText("1 checked in");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.44.0",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.44.0",
"version": "1.47.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.44.0",
"version": "1.47.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -97,12 +97,26 @@ const ACTION_LABELS: Record<string, string> = {
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Cross Events Hub",
hub_update_requested: "Asked the Hub for an update",
hub_update_request_withdrawn: "Withdrew the update request",
hub_update_snoozed: "Put off an update (Not now)",
hub_update_unsnoozed: "Turned update reminders back on",
campuses_toggled: "Campuses turned on/off",
campus_created: "Campus added",
campus_updated: "Campus changed",
campus_archived: "Campus archived/restored",
campus_deleted: "Campus deleted",
event_campus_changed: "Event moved to another campus",
user_campus_scope_changed: "Staff member limited to campuses",
kids_settings_updated: "Kids check-in settings changed",
kids_checkout_override: "Child checked out without the pickup code",
kids_wrong_code: "Wrong pickup code entered",
kids_custody_notes_changed: "Child's custody notes changed",
kids_offline_sync: "Offline kids desk synced",
volunteer_settings_updated: "Volunteer ticket changed",
volunteer_roles_changed: "Serving role added, changed or deleted",
volunteer_assigned: "Volunteer put on a roster",
volunteer_removed: "Volunteer taken off a roster",
volunteer_checked_in_manual: "Volunteer checked in by hand",
};
interface AuditLogEntry {
@@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -30,6 +31,9 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
+3
View File
@@ -8,6 +8,7 @@ import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
import { ProviderNotices } from "@/components/shared/ProviderNotices";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -89,6 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
@@ -106,6 +108,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="min-h-screen flex flex-col">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
@@ -0,0 +1,569 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, ApiError } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, CloudOff, LogIn, LogOut, QrCode, RefreshCw, Search, UserPlus, Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { QRScanner } from "@/components/qr/QRScanner";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard } from "@/components/kids/RoomBoard";
import { StickerPanel } from "@/components/kids/StickerPanel";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import {
FAMILY_QR_PREFIX, QR_PREFIX, deviceId, loadOffline, offlineCheckIn, saveOffline, stickerAlert, suggestRoom,
type CheckInResult, type KidsBoard, type KidsFamily, type OfflineState,
} from "@/lib/kids";
type Tab = "in" | "out" | "rooms";
type Msg = { type: "ok" | "err"; text: string } | null;
type CodeLookup = {
code: string | null; found: boolean;
family?: { name: string; parents: { userId: string; name: string; phone: string | null }[]; adults: { id: string; name: string; relationship: string | null }[] } | null;
children: { id: string; name: string; room: { id: string; name: string } | null; checkedOutAt: string | null; collectedBy: string | null }[];
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const tabCls = (on: boolean) => `px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-1.5 ${on ? "bg-brand-600 text-white" : "bg-white border text-gray-700"}`;
/** The kids check-in desk: find the family, check children in (pickup code + sticker panel), check them out by code. */
export default function KidsDeskPage() {
const { token, user } = useAuth();
const isSupervisor = user?.role === "admin" || user?.role === "supervisor";
const [eventId, setEventId] = useState("");
const [sessionId, setSessionId] = useState("");
const [tab, setTab] = useState<Tab>("in");
const [board, setBoard] = useState<KidsBoard | null>(null);
const [boardError, setBoardError] = useState<string | null>(null);
const [msg, setMsg] = useDismissingState<Msg>(null, 9000);
const [busy, setBusy] = useState(false);
// Offline
const [offline, setOffline] = useState<OfflineState | null>(null);
const [working, setWorking] = useState<"online" | "offline">("online");
// Check in
const [q, setQ] = useState("");
const [results, setResults] = useState<KidsFamily[] | null>(null);
const [family, setFamily] = useState<KidsFamily | null>(null);
const [picked, setPicked] = useState<Record<string, string>>({}); // childId → roomId
const [sendTo, setSendTo] = useState("");
const [result, setResult] = useState<CheckInResult | null>(null);
const [scanning, setScanning] = useState(false);
const [newFamily, setNewFamily] = useState<{ name: string; parentName: string; phone: string; email: string } | null>(null);
const [addingChild, setAddingChild] = useState(false);
// Check out
const [code, setCode] = useState("");
const [lookup, setLookup] = useState<CodeLookup | null>(null);
const [outPicked, setOutPicked] = useState<Record<string, boolean>>({});
const [collectedBy, setCollectedBy] = useState("");
const [wrongCode, setWrongCode] = useState<string | null>(null);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const loadBoard = useCallback(async (log = false) => {
if (!token || !eventId || working === "offline") return;
try {
const data = await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token });
setBoard(data);
setBoardError(null);
} catch (e) {
setBoard(null);
setBoardError(errorMessage(e) || "Could not load the desk");
}
}, [token, eventId, qs, working]);
useEffect(() => {
setBoard(null);
setFamily(null);
setResult(null);
setOffline(eventId ? loadOffline(eventId) : null);
loadBoard(true);
}, [eventId, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
useVisiblePolling(() => { loadBoard(); }, 10000, !!token && !!eventId && working === "online");
// Go offline by itself when the browser loses its connection (if an offline list is ready).
useEffect(() => {
const down = () => { if (loadOffline(eventId)) setWorking("offline"); };
window.addEventListener("offline", down);
return () => window.removeEventListener("offline", down);
}, [eventId]);
const rooms = useMemo(() => (working === "offline" ? offline?.snapshot.rooms : board?.rooms) || [], [working, offline, board]);
const inNow = useMemo(() => {
const m = new Map<string, string>(); // childId → room name
const list = working === "offline" ? offline?.snapshot.checkIns : board?.checkIns;
for (const c of list || []) if (!c.checkedOutAt && c.childId) m.set(c.childId, rooms.find(r => r.id === c.roomId)?.name || "");
if (working === "offline") for (const c of offline?.checkIns || []) if (!offline!.checkOuts.some(o => o.clientId === c.clientId)) m.set(c.childId, c.roomName);
return m;
}, [working, offline, board, rooms]);
const fail = (e: unknown, fallback: string) => {
// A dropped connection with an offline list ready: switch over instead of failing.
if (!(e instanceof ApiError) && loadOffline(eventId)) {
setWorking("offline");
setOffline(loadOffline(eventId));
setMsg({ type: "err", text: "No connection — switched to the offline list. Try that again." });
return;
}
setMsg({ type: "err", text: errorMessage(e) || fallback });
};
// ─── Families ───────────────────────────────────────────────────────────────
const chooseFamily = (f: KidsFamily) => {
setFamily(f);
setResults(null);
setResult(null);
setSendTo("");
setAddingChild(false);
const extra: Record<string, number> = {};
const next: Record<string, string> = {};
for (const c of f.children) {
if (inNow.has(c.id)) continue;
const room = suggestRoom(rooms, c.age, extra);
if (room) { next[c.id] = room.id; extra[room.id] = (extra[room.id] || 0) + 1; }
}
setPicked(next);
};
const search = async (e?: React.FormEvent) => {
e?.preventDefault();
const term = q.trim();
if (term.length < 2) return;
if (working === "offline") {
const t = term.toLowerCase(), digits = term.replace(/\D/g, "");
setResults((offline?.snapshot.families || []).filter(f =>
f.name.toLowerCase().includes(t)
|| f.children.some(c => c.name.toLowerCase().includes(t))
|| f.parents.some(p => p.name.toLowerCase().includes(t) || (digits.length >= 6 && (p.phone || "").replace(/\D/g, "").slice(-9).includes(digits.slice(-9))))).slice(0, 20));
return;
}
try {
setResults(await apiFetch<KidsFamily[]>(`/api/kids/events/${eventId}/families?q=${encodeURIComponent(term)}`, { authToken: token }));
} catch (err) {
fail(err, "Search failed");
}
};
const onScan = async (text: string) => {
setScanning(false);
const t = text.trim();
if (t.toUpperCase().startsWith(QR_PREFIX)) {
setTab("out");
setCode(t.slice(QR_PREFIX.length));
lookUpCode(t.slice(QR_PREFIX.length));
return;
}
if (!t.toUpperCase().startsWith(FAMILY_QR_PREFIX)) { setMsg({ type: "err", text: "That isn't a family or pickup QR code." }); return; }
if (working === "offline") { setMsg({ type: "err", text: "Family QR codes need a connection — search by name or phone instead." }); return; }
try {
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/qr/${encodeURIComponent(t.slice(FAMILY_QR_PREFIX.length))}`, { authToken: token }));
} catch (err) {
fail(err, "Family not found");
}
};
const createFamily = async (child: ChildInput) => {
if (!newFamily) return;
setBusy(true);
try {
const f = await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families`, {
method: "POST", authToken: token,
body: { name: newFamily.name, parent: { name: newFamily.parentName, phone: newFamily.phone, email: newFamily.email }, children: [child] },
});
setNewFamily(null);
chooseFamily(f);
setMsg({ type: "ok", text: `${f.name} added.` });
} catch (err) {
fail(err, "Could not add the family");
} finally {
setBusy(false);
}
};
const addChild = async (child: ChildInput) => {
if (!family) return;
setBusy(true);
try {
await apiFetch(`/api/kids/events/${eventId}/families/${family.id}/children`, { method: "POST", authToken: token, body: child });
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/${family.id}`, { authToken: token }));
setMsg({ type: "ok", text: `${child.firstName} added.` });
} catch (err) {
fail(err, "Could not add the child");
} finally {
setBusy(false);
}
};
// ─── Check in ───────────────────────────────────────────────────────────────
const doCheckIn = async () => {
if (!family) return;
const children = Object.entries(picked).filter(([, roomId]) => roomId).map(([childId, roomId]) => ({ childId, roomId }));
if (!children.length) { setMsg({ type: "err", text: "Tick the children to check in." }); return; }
if (working === "offline" && offline) {
try {
const picks = children.map(p => ({ child: family.children.find(c => c.id === p.childId)!, room: rooms.find(r => r.id === p.roomId)! }));
const out = offlineCheckIn(offline, family, picks);
setOffline(out.state);
saveOffline(eventId, out.state);
setResult({
code: out.code, qr: QR_PREFIX + out.code, occasion: offline.snapshot.occasion, family: { id: family.id, name: family.name },
checkIns: picks.map(p => ({ id: p.child.id, name: p.child.name, firstName: p.child.firstName, room: { id: p.room.id, name: p.room.name }, already: false, sticker: stickerAlert(p.child.allergies, p.child.hasMedical) })),
});
setMsg({ type: "ok", text: "Checked in on this device — tell the parent the code (no message is sent while offline)." });
} catch (err) {
setMsg({ type: "err", text: errorMessage(err) || "Could not check in offline" });
}
return;
}
setBusy(true);
try {
const r = await apiFetch<CheckInResult>(`/api/kids/events/${eventId}/check-in`, {
method: "POST", authToken: token, body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
});
setResult(r);
loadBoard();
} catch (err) {
fail(err, "Check-in failed");
} finally {
setBusy(false);
}
};
// ─── Check out ──────────────────────────────────────────────────────────────
const offlineLookup = (raw: string): CodeLookup => {
const c = raw.trim().toUpperCase().replace(/[\s-]/g, "");
const mine = (offline?.checkIns || []).filter(x => x.code === c);
const fam = offline?.snapshot.families.find(f => f.id === mine[0]?.familyId);
return {
code: c, found: mine.length > 0,
family: fam ? { name: fam.name, parents: fam.parents, adults: fam.adults } : null,
children: mine.map(x => {
const o = offline!.checkOuts.find(k => k.clientId === x.clientId);
return { id: x.clientId, name: x.name, room: { id: x.roomId, name: x.roomName }, checkedOutAt: o?.at || null, collectedBy: o?.collectedBy || null };
}),
};
};
const lookUpCode = async (raw = code) => {
setWrongCode(null);
setLookup(null);
if (!raw.trim()) return;
let l: CodeLookup;
if (working === "offline") {
l = offlineLookup(raw);
if (!l.found) {
setWrongCode(offline?.snapshot.checkIns.some(c => c.code === raw.trim().toUpperCase())
? "This code was given out before going offline. Check these children out on a connected device, or use \"Without code\" there."
: "That code doesn't match any child checked in on this device.");
return;
}
} else {
try {
l = await apiFetch<CodeLookup>(`/api/kids/events/${eventId}/code/${encodeURIComponent(raw.trim())}${qs}`, { authToken: token });
} catch (err) {
fail(err, "Lookup failed");
return;
}
if (!l.found) {
// Record the wrong code (alerts the rooms) through the check-out call.
try {
await apiFetch(`/api/kids/events/${eventId}/check-out`, { method: "POST", authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
} catch (err) {
setWrongCode(errorMessage(err) || "Wrong code");
}
loadBoard();
return;
}
}
setLookup(l);
setOutPicked(Object.fromEntries(l.children.filter(c => !c.checkedOutAt).map(c => [c.id, true])));
setCollectedBy(l.family?.parents[0]?.name || "");
};
const doCheckOut = async () => {
if (!lookup) return;
const ids = Object.entries(outPicked).filter(([, on]) => on).map(([id]) => id);
if (!ids.length) { setMsg({ type: "err", text: "Tick the children being collected." }); return; }
if (!collectedBy.trim()) { setMsg({ type: "err", text: "Enter who is collecting." }); return; }
if (working === "offline" && offline) {
const at = new Date().toISOString();
const next = { ...offline, checkOuts: [...offline.checkOuts, ...ids.map(clientId => ({ clientId, collectedBy, at }))] };
setOffline(next);
saveOffline(eventId, next);
setMsg({ type: "ok", text: `Checked out on this device: ${lookup.children.filter(c => ids.includes(c.id)).map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
return;
}
setBusy(true);
try {
const r = await apiFetch<{ checkedOut: { name: string }[] }>(`/api/kids/events/${eventId}/check-out`, {
method: "POST", authToken: token, body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
});
setMsg({ type: "ok", text: `Collected: ${r.checkedOut.map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
loadBoard();
} catch (err) {
fail(err, "Check-out failed");
} finally {
setBusy(false);
}
};
// ─── Offline list ───────────────────────────────────────────────────────────
const getReady = async () => {
setBusy(true);
try {
const snapshot = await apiFetch<OfflineState["snapshot"]>(`/api/kids/events/${eventId}/offline?deviceId=${encodeURIComponent(deviceId())}${sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` : ""}`, { authToken: token });
const prev = loadOffline(eventId);
const sameOccasion = prev?.snapshot.occasion === snapshot.occasion;
const state: OfflineState = {
snapshot,
familyCodes: sameOccasion ? prev!.familyCodes : {},
checkIns: sameOccasion ? prev!.checkIns : [],
checkOuts: sameOccasion ? prev!.checkOuts : [],
};
saveOffline(eventId, state);
setOffline(state);
setMsg({ type: "ok", text: `Offline list ready: ${snapshot.families.length} families, ${snapshot.codes.length} pickup codes for this device.` });
} catch (err) {
fail(err, "Could not download the offline list");
} finally {
setBusy(false);
}
};
const sync = async () => {
if (!offline) return;
setBusy(true);
try {
const r = await apiFetch<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>(`/api/kids/events/${eventId}/sync`, {
method: "POST", authToken: token,
body: { deviceId: deviceId(), occasion: offline.snapshot.occasion, checkIns: offline.checkIns, checkOuts: offline.checkOuts },
});
const next: OfflineState = { ...offline, checkIns: [], checkOuts: [] };
saveOffline(eventId, next);
setOffline(next);
setWorking("online");
setMsg({
type: r.conflicts.length ? "err" : "ok",
text: `Synced: ${r.checkedIn} checked in, ${r.checkedOut} checked out.${r.conflicts.length ? ` Check: ${r.conflicts.map(c => c.message).join(" ")}` : ""}`,
});
loadBoard();
} catch (err) {
setMsg({ type: "err", text: errorMessage(err) || "Sync failed — still offline? Your check-ins are kept on this device." });
} finally {
setBusy(false);
}
};
const queued = (offline?.checkIns.length || 0) + (offline?.checkOuts.length || 0);
const ready = working === "offline" ? !!offline : !!board;
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids check-in</h1>
<p className="text-sm text-gray-500">{board?.sessionLabel ? `${board.sessionLabel} · ` : ""}Check children in and out with pickup codes</p>
</div>
</div>
<Link href="/dashboard/staff/kids/rooms" className="text-sm px-3 py-2 rounded-lg border inline-flex items-center gap-1.5"><Users className="w-4 h-4" /> Room screen</Link>
</div>
<KidsEventPicker token={token} eventId={eventId} sessionId={sessionId} onChange={(id, sid) => { setEventId(id); setSessionId(sid); }} />
{eventId && (
<div className={`mt-3 p-3 rounded-lg border text-sm flex flex-wrap items-center gap-2 ${working === "offline" ? "bg-amber-50 border-amber-300" : "bg-gray-50"}`}>
<CloudOff className="w-4 h-4 text-gray-500" />
{working === "offline" ? (
<>
<span className="font-medium text-amber-900">Working offline</span>
<span className="text-amber-800">{queued} change{queued === 1 ? "" : "s"} waiting to sync · {Math.max(0, (offline?.snapshot.codes.length || 0) - Object.keys(offline?.familyCodes || {}).length)} codes left</span>
<button disabled={busy} onClick={sync} className="ml-auto px-3 py-1.5 rounded-lg bg-amber-600 text-white inline-flex items-center gap-1"><RefreshCw className="w-3.5 h-3.5" /> Back online — sync</button>
</>
) : (
<>
<span className="text-gray-700">{offline ? `Offline list from ${new Date(offline.snapshot.downloadedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "Wi-Fi unreliable? Download the list first."}</span>
{queued > 0 && <button disabled={busy} onClick={sync} className="px-3 py-1.5 rounded-lg bg-amber-600 text-white">Sync {queued} offline change{queued === 1 ? "" : "s"}</button>}
<button disabled={busy} onClick={getReady} className="ml-auto px-3 py-1.5 rounded-lg border bg-white">{offline ? "Refresh offline list" : "Get ready for offline"}</button>
{offline && <button onClick={() => setWorking("offline")} className="px-3 py-1.5 rounded-lg border bg-white">Work offline</button>}
</>
)}
</div>
)}
{boardError && working === "online" && <div className="mt-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{boardError}</div>}
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{ready && (
<>
<div className="flex flex-wrap gap-2 my-4">
<button className={tabCls(tab === "in")} onClick={() => setTab("in")}><LogIn className="w-4 h-4" /> Check in</button>
<button className={tabCls(tab === "out")} onClick={() => setTab("out")}><LogOut className="w-4 h-4" /> Check out</button>
{working === "online" && <button className={tabCls(tab === "rooms")} onClick={() => setTab("rooms")}><Users className="w-4 h-4" /> Who&apos;s in ({board?.checkIns.filter(c => !c.checkedOutAt).length || 0})</button>}
</div>
{(tab === "in" || tab === "out") && (
<div className="mb-4">
<button onClick={() => setScanning(s => !s)} className="text-sm px-3 py-2 rounded-lg border inline-flex items-center gap-1.5 bg-white"><QrCode className="w-4 h-4" /> {scanning ? "Stop scanning" : tab === "in" ? "Scan family QR" : "Scan pickup QR"}</button>
{scanning && <div className="mt-2 max-w-sm"><QRScanner onResult={onScan} /></div>}
</div>
)}
{tab === "in" && (
result ? (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<StickerPanel code={result.code} offline={working === "offline"} kids={result.checkIns.map(c => ({ id: c.id, firstName: c.firstName, room: c.room?.name || null, sticker: c.sticker, already: c.already }))} />
<button onClick={() => { setResult(null); setFamily(null); setQ(""); }} className="mt-4 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Next family</button>
</section>
) : (
<div className="grid lg:grid-cols-2 gap-4">
<section className="border rounded-xl p-4 bg-white shadow-sm">
<form onSubmit={search} className="flex gap-2">
<input className={inputCls} placeholder="Parent or child name, or phone number" value={q} onChange={e => setQ(e.target.value)} aria-label="Find a family" />
<button className="px-3 py-2 rounded-lg border" aria-label="Search"><Search className="w-4 h-4" /></button>
</form>
{results && (
<ul className="mt-3 divide-y">
{results.map(f => (
<li key={f.id}>
<button onClick={() => chooseFamily(f)} className="w-full text-left py-2 hover:bg-gray-50 px-1 rounded">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-gray-500">{f.parents.map(p => p.name).join(", ")} · {f.children.map(c => c.firstName).join(", ") || "no children yet"}</div>
</button>
</li>
))}
{!results.length && <li className="py-2 text-sm text-gray-500">No family found.</li>}
</ul>
)}
{working === "online" && !newFamily && (
<button onClick={() => setNewFamily({ name: "", parentName: "", phone: "", email: "" })} className="mt-3 text-sm inline-flex items-center gap-1 text-brand-700"><UserPlus className="w-4 h-4" /> New family</button>
)}
{newFamily && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="font-medium text-sm">New family</div>
<input className={inputCls} placeholder="Parent's name *" value={newFamily.parentName} onChange={e => setNewFamily({ ...newFamily, parentName: e.target.value })} />
<div className="grid grid-cols-2 gap-2">
<input className={inputCls} placeholder="Phone" value={newFamily.phone} onChange={e => setNewFamily({ ...newFamily, phone: e.target.value })} />
<input className={inputCls} placeholder="Email" value={newFamily.email} onChange={e => setNewFamily({ ...newFamily, email: e.target.value })} />
</div>
<input className={inputCls} placeholder="Family name (e.g. Smith family)" value={newFamily.name} onChange={e => setNewFamily({ ...newFamily, name: e.target.value })} />
<div className="text-xs text-gray-500">First child:</div>
<ChildForm custody={isSupervisor} busy={busy} onSave={createFamily} onCancel={() => setNewFamily(null)} saveLabel="Add family" />
</div>
)}
</section>
{family && (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold">{family.name}</h2>
<div className="text-xs text-gray-500 mb-3">{family.parents.map(p => `${p.name}${p.phone ? ` · ${p.phone}` : ""}`).join(" — ")}</div>
<ul className="space-y-2">
{family.children.map(c => {
const inRoom = inNow.get(c.id);
return (
<li key={c.id} className="border rounded-lg p-2 flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 flex-1 min-w-[10rem]">
<input type="checkbox" disabled={inRoom !== undefined} checked={!!picked[c.id]} onChange={e => setPicked(p => ({ ...p, [c.id]: e.target.checked ? (suggestRoom(rooms, c.age)?.id || "") : "" }))} />
<span>
<span className="font-medium">{c.name}</span>{c.age != null && <span className="text-sm text-gray-500"> · {c.age}</span>}
{c.allergies && <span className="block text-xs text-red-700">Allergies: {c.allergies}</span>}
{c.hasCustody && <span className="block text-xs text-red-900">Custody notes — see room screen</span>}
</span>
</label>
{inRoom !== undefined ? <span className="text-xs px-2 py-1 rounded-full bg-green-50 text-green-700">In — {inRoom}</span> : picked[c.id] ? (
<select aria-label={`Room for ${c.firstName}`} className="border rounded-lg px-2 py-1 text-sm" value={picked[c.id]} onChange={e => setPicked(p => ({ ...p, [c.id]: e.target.value }))}>
{rooms.map(r => <option key={r.id} value={r.id}>{r.name}{r.full ? " (full)" : ""}</option>)}
</select>
) : null}
</li>
);
})}
</ul>
{working === "online" && (addingChild ? (
<div className="mt-3 border-t pt-3"><ChildForm custody={isSupervisor} busy={busy} onSave={addChild} onCancel={() => setAddingChild(false)} saveLabel="Add child" /></div>
) : (
<button onClick={() => setAddingChild(true)} className="mt-2 text-sm text-brand-700">+ Add a child</button>
))}
{working === "online" && family.adults.some(a => a.phone) && (
<label className="block text-sm mt-3"><span className="block text-xs text-gray-500 mb-1">Also send the code to</span>
<select className={inputCls} value={sendTo} onChange={e => setSendTo(e.target.value)}>
<option value="">Nobody else</option>
{family.adults.filter(a => a.phone).map(a => <option key={a.id} value={a.id}>{a.name}{a.relationship ? ` (${a.relationship})` : ""}</option>)}
</select>
</label>
)}
<button disabled={busy || !Object.values(picked).some(Boolean)} onClick={doCheckIn} className="mt-4 w-full px-4 py-3 rounded-lg bg-brand-600 text-white font-semibold disabled:opacity-50">Check in</button>
</section>
)}
</div>
)
)}
{tab === "out" && (
<section className="border rounded-xl p-4 bg-white shadow-sm max-w-xl">
<form onSubmit={e => { e.preventDefault(); lookUpCode(); }} className="flex gap-2">
<input className={`${inputCls} text-2xl font-mono tracking-widest uppercase`} placeholder="Pickup code" value={code} onChange={e => setCode(e.target.value)} maxLength={10} aria-label="Pickup code" />
<button className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Find</button>
</form>
{wrongCode && <div role="alert" className="mt-3 p-3 rounded-lg border-2 border-red-500 bg-red-50 text-red-900 text-sm font-medium">{wrongCode}</div>}
{lookup && (
<div className="mt-4">
{lookup.family && (
<div className="text-sm mb-3">
<div className="font-semibold">{lookup.family.name}</div>
<div className="text-xs text-gray-600">Parents: {lookup.family.parents.map(p => p.name).join(", ") || "—"}</div>
{lookup.family.adults.length > 0 && <div className="text-xs text-gray-600">May also collect: {lookup.family.adults.map(a => `${a.name}${a.relationship ? ` (${a.relationship})` : ""}`).join(", ")}</div>}
</div>
)}
<ul className="space-y-1 mb-3">
{lookup.children.map(c => {
const custody = board?.checkIns.find(b => b.id === c.id)?.custodyNotes;
return (
<li key={c.id} className="text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" disabled={!!c.checkedOutAt} checked={!!outPicked[c.id]} onChange={e => setOutPicked(p => ({ ...p, [c.id]: e.target.checked }))} />
<span className="font-medium">{c.name}</span> <span className="text-gray-500">{c.room?.name}</span>
{c.checkedOutAt && <span className="text-xs text-gray-500">collected by {c.collectedBy || "—"}</span>}
</label>
{custody && <div className="ml-6 mt-1 text-xs text-red-900 bg-red-50 rounded px-2 py-1">Custody: {custody}</div>}
</li>
);
})}
</ul>
<label className="block text-sm"><span className="block text-xs text-gray-500 mb-1">Collected by</span>
<input className={inputCls} list="kids-collectors" value={collectedBy} onChange={e => setCollectedBy(e.target.value)} />
<datalist id="kids-collectors">
{lookup.family?.parents.map(p => <option key={p.userId} value={p.name} />)}
{lookup.family?.adults.map(a => <option key={a.id} value={a.name} />)}
</datalist>
</label>
<button disabled={busy} onClick={doCheckOut} className="mt-3 w-full px-4 py-3 rounded-lg bg-brand-600 text-white font-semibold disabled:opacity-50">Check out</button>
</div>
)}
</section>
)}
{tab === "rooms" && board && working === "online" && (
<RoomBoard board={board} token={token} roomId="" onChanged={() => loadBoard()} onMessage={setMsg} />
)}
</>
)}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard, RoomStatusBadges } from "@/components/kids/RoomBoard";
import type { KidsBoard } from "@/lib/kids";
const ROOM_KEY = "hope_events_kids_room";
/** A kids room's screen: the children in it, allergies and custody notes, "I'm on duty", alerts. */
export default function KidsRoomsPage() {
const { token, user } = useAuth();
const [eventId, setEventId] = useState("");
const [sessionId, setSessionId] = useState("");
const [roomId, setRoomId] = useState("");
const [board, setBoard] = useState<KidsBoard | null>(null);
const [error, setError] = useState<string | null>(null);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
useEffect(() => { try { setRoomId(localStorage.getItem(ROOM_KEY) || ""); } catch { /* ignore */ } }, []);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const load = useCallback(async (log = false) => {
if (!token || !eventId) return;
try {
setBoard(await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token }));
setError(null);
} catch (e) {
setBoard(null);
setError(errorMessage(e) || "Could not load the rooms");
}
}, [token, eventId, qs]);
useEffect(() => { load(true); }, [load]);
useVisiblePolling(() => { load(); }, 5000, !!token && !!eventId);
const pickRoom = (id: string) => {
setRoomId(id);
try { localStorage.setItem(ROOM_KEY, id); } catch { /* ignore */ }
};
const room = board?.rooms.find(r => r.id === roomId) || null;
const onDuty = !!room && !!user && room.leaderIds.includes(user.id);
const duty = async (on: boolean) => {
try {
await apiFetch(`/api/kids/events/${eventId}/duty`, { method: "POST", authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
load();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not change duty" });
}
};
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Users className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard/staff/kids" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids rooms</h1>
<p className="text-sm text-gray-500">{board?.sessionLabel ? `${board.sessionLabel} · ` : ""}Who is in each room right now</p>
</div>
</div>
<div className="flex flex-wrap gap-2 items-end">
<KidsEventPicker token={token} eventId={eventId} sessionId={sessionId} onChange={(id, sid) => { setEventId(id); setSessionId(sid); }} />
{board && (
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Room</span>
<select className="border rounded-lg px-3 py-2 text-sm" value={room ? roomId : ""} onChange={e => pickRoom(e.target.value)}>
<option value="">All rooms</option>
{board.rooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
)}
</div>
{room && (
<div className="mt-3 p-3 rounded-lg border bg-white flex flex-wrap items-center gap-3">
<RoomStatusBadges room={room} />
<button onClick={() => duty(!onDuty)} className={`ml-auto px-4 py-2 rounded-lg text-sm font-medium ${onDuty ? "border" : "bg-brand-600 text-white"}`}>
{onDuty ? "I'm going off duty" : "I'm on duty in this room"}
</button>
</div>
)}
{error && <div className="mt-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{board && <div className="mt-4"><RoomBoard board={board} token={token} roomId={room ? roomId : ""} onChanged={() => load()} onMessage={setMsg} /></div>}
</div>
);
}
+3 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
@@ -16,6 +16,8 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/staff/volunteers", label: "Volunteers", description: "Who is serving today, check-ins and the check-in QR code", icon: HandHeart },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
] as const;
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import type { RosterEvent } from "@/lib/volunteers";
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
export default function StaffVolunteersPage() {
const { token } = useAuth();
const [events, setEvents] = useState<RosterEvent[] | null>(null);
const [eventId, setEventId] = useState("");
const [showQr, setShowQr] = useState(false);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
.then(list => {
setEvents(list);
const now = Date.now();
const current = list.find(e => new Date(e.endDate).getTime() >= now);
if (current) setEventId(current.id);
})
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const event = events?.find(e => e.id === eventId);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4 print:hidden">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteers</h1>
<p className="text-sm text-gray-500">Who&apos;s serving today, and checking them in</p>
</div>
</div>
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
{events && events.length > 0 && (
<div className="flex flex-wrap gap-2 items-end print:hidden">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{!eventId && <option value="">Choose an event…</option>}
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
</select>
</label>
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
</div>
)}
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
<div className="mt-4">
{eventId && (showQr
? <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />
: <RosterSheet token={token} eventId={eventId} canEdit={false} onMessage={setMsg} />)}
</div>
</div>
);
}
@@ -10,7 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { Calendar } from "lucide-react";
import { Calendar, Copy } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
@@ -870,14 +870,49 @@ const blankOptions = (): OptionDraft[] => [
interface EventModalProps {
mode: "create" | "edit";
event?: Event | null;
// Create mode: start from a copy of this event (cloning).
source?: Event | null;
onClose: () => void;
onSuccess: () => void;
}
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
/** A source event's ticket options as drafts for a copy — no ids, and without the staff-only
* kids and volunteer tickets (kids check-in and rosters aren't copied). `sourceIds` lines up
* with `drafts`, to map the source's sections onto the copy. */
function cloneOptions(source: Event): { drafts: OptionDraft[]; sourceIds: string[] } {
const staffOnly = new Set([source.kidsOptionId, source.volunteerOptionId].filter(Boolean));
const kept = (source.eventOptions || []).filter(o => !staffOnly.has(o.id));
const tier = (t: { deadline: string; price: number; stockLimit?: number | null }): Tier => ({ deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") });
return {
sourceIds: kept.map(o => o.id),
drafts: kept.map(o => ({
name: o.name,
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
sessionMode: o.sessionMode || "all",
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => !t.variantId).map(tier),
variants: (o.variants || []).map(v => ({
name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => t.variantId === v.id).map(tier),
})),
})),
};
}
const withoutIds = (fields: FieldDef[]): FieldDef[] => fields.map(f => ({ ...f, id: undefined }));
function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalProps) {
const { token } = useAuth();
const { settings } = useSiteSettings();
const campusInfo = useMyCampuses();
// Cloning fills "New event" from the source; everything can still be changed before saving.
const cloning = mode === "create" && !!source;
const base = mode === "edit" ? ev : source;
// Campus-scoped: events for all campuses (and other campuses') are set up by admins and
// supervisors for every campus — the server refuses changes, so the editor says so up front.
const campusReadOnly = mode === "edit" && campusInfo.scoped && !campusInfo.campuses.some(c => c.id === ev?.campusId);
@@ -888,26 +923,27 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── event draft ──
// New events default their location to the organisation's address (settings.org_address);
// editing an existing event always reflects its own saved location instead.
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
allowNamedAttendees: !!ev.allowNamedAttendees,
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
allowTicketTransfer: ev.allowTicketTransfer !== false,
collectGender: !!ev.collectGender,
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
campusId: ev.campusId || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
// editing an existing event always reflects its own saved location instead. A copy keeps the
// source's, but not its short link (those are unique) or its go-live time.
const [draft, setDraft] = useState<EventDraft>(() => base ? {
title: base.title || "", description: base.description || "",
startDate: toLocalDT(base.startDate), endDate: toLocalDT(base.endDate),
registrationDeadline: toLocalDT(base.registrationDeadline), goLiveAt: cloning ? "" : toLocalDT(base.goLiveAt),
price: String(base.price ?? ""), picture: base.picture || "", redirectUrl: cloning ? "" : base.redirectUrl || "",
isActive: cloning || base.isActive !== false, isHidden: !!base.isHidden, requiresAuth: base.requiresAuth !== false,
requiresRegistration: base.requiresRegistration !== false,
allowNamedAttendees: !!base.allowNamedAttendees,
requiresParentalConsent: !!base.requiresParentalConsent, consentText: base.consentText || "",
consentCollectMedical: !!base.consentCollectMedical, consentCollectEmergency: !!base.consentCollectEmergency, consentCollectPhoto: !!base.consentCollectPhoto,
waitlistEnabled: !!base.waitlistEnabled, waitlistOfferHours: String(base.waitlistOfferHours ?? 24), waitlistPartialOffers: base.waitlistPartialOffers !== false,
allowTicketTransfer: base.allowTicketTransfer !== false,
collectGender: !!base.collectGender,
allowSponsorships: !!base.allowSponsorships, sponsorshipGoalSpots: base.sponsorshipGoalSpots ? String(base.sponsorshipGoalSpots) : "", showSponsorshipProgress: base.showSponsorshipProgress !== false,
contactName: base.contactName || "", contactPhone: base.contactPhone || "", contactEmail: base.contactEmail || "",
location: base.location || "",
campusId: base.campusId || "",
scheduleType: base.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: base.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
// A campus-scoped person's new event starts at their (first) campus — they can't make
@@ -923,18 +959,22 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
// ── multi-day schedule + attendance settings ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
// A copy leaves out cancelled sessions and saves the rest as new ones; each keeps the source
// session's id as its key so "late joining until" still points at the same session.
const [sessions, setSessions] = useState<KeyedSession[]>(() => cloning
? (base?.sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).map(s => ({ ...toSessionDraft(s), id: undefined, hasCheckIns: false, key: s.id }))
: (base?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
lateJoinEnabled: !!ev?.lateJoinEnabled,
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
latePricing: ev?.latePricing || "full",
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
checkInWindow: ev?.checkInWindow || "whole_day",
remindersEnabled: !!ev?.sessionReminders,
reminderOffsets: ev?.sessionReminders?.offsets || [180],
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!ev?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
lateJoinEnabled: !!base?.lateJoinEnabled,
lateJoinUntilKey: base?.lateJoinUntilSessionId || "",
latePricing: base?.latePricing || "full",
lateFixedPrice: base?.lateFixedPrice !== null && base?.lateFixedPrice !== undefined ? String(base.lateFixedPrice) : "",
checkInWindow: base?.checkInWindow || "whole_day",
remindersEnabled: !!base?.sessionReminders,
reminderOffsets: base?.sessionReminders?.offsets || [180],
reminderChannels: base?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!base?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(base?.certificateConfig || {}) },
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
@@ -950,7 +990,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
// ── options (with per-variant tiers) ──
const [cloned] = useState(() => (cloning && source ? cloneOptions(source) : null));
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (cloned) {
if (!cloned.drafts.length) return blankOptions();
return cloned.drafts.some(o => o.isMainTicket) ? cloned.drafts : cloned.drafts.map((o, i) => ({ ...o, isMainTicket: i === 0 }));
}
if (!ev?.eventOptions?.length) return blankOptions();
return ev.eventOptions.map((o) => {
const allTiers = o.earlyBirdTiers || [];
@@ -982,15 +1027,32 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── sections (staged for create mode) ──
const [sectionDrafts, setSectionDrafts] = useState<SectionDraft[]>([]);
// A copy takes the source's sections onto the copied options. A section whose options were all
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
useEffect(() => {
if (!cloned || !source?.id || !token) return;
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
const drafts: SectionDraft[] = [];
for (const sec of list || []) {
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
const optionIndices = ids.map(id => cloned.sourceIds.indexOf(id)).filter(i => i >= 0);
if (ids.length && !optionIndices.length) continue;
drafts.push({ name: sec.name, optionIndices });
}
setSectionDrafts(drafts);
}).catch(() => {});
}, [cloned, source?.id, token]);
// ── attachments (staged for create mode; uploaded after event is created) ──
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
// ── notify recipients (staged for create mode; saved after event is created) ──
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>([]);
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>(() => (cloning ? source?.notifyRecipients || [] : []));
// ── form ──
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formDef, setFormDef] = useState<EventFormDef>(() => (cloning && source?.form
? { isRequired: !!source.form.isRequired, fields: withoutIds(toFieldDefs(source.form.fields)) }
: { isRequired: false, fields: [] }));
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
@@ -1005,16 +1067,26 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [fbDef, setFbDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [fbSettings, setFbSettings] = useState({ sendAt: "", audience: "checked_in", anonymous: false, intro: "" });
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
// How far a copy's dates have been moved so far, for the source's feedback date if it arrives
// after the move.
const shiftedRef = useRef(0);
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
useEffect(() => {
if (mode !== "edit" || !ev?.id || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
if (!feedbackFrom || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
if (!f) return;
setFbEnabled(true);
if (cloning) {
// The questions and settings only — not whether it was sent or who answered.
setFbDef({ isRequired: false, fields: withoutIds(toFieldDefs(f.fields)) });
setFbSettings({ sendAt: f.sendAt ? toLocalDT(new Date(new Date(f.sendAt).getTime() + shiftedRef.current)) : "", audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
return;
}
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
}).catch(() => {});
}, [mode, ev?.id, token]);
}, [feedbackFrom, cloning, token]);
const enableFeedback = (on: boolean) => {
setFbEnabled(on);
if (on && fbDef.fields.length === 0) {
@@ -1050,9 +1122,24 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
// ── copy: move every date by the same amount (sessions, deadline, early birds, feedback) ──
const copyStart = multi ? scheduleBounds?.start || "" : draft.startDate;
const moveAllDates = (newStart: string) => {
const ms = newStart && copyStart ? new Date(newStart).getTime() - new Date(copyStart).getTime() : NaN;
if (!ms || isNaN(ms)) return;
shiftedRef.current += ms;
const sh = (v: string) => (v ? toLocalDT(new Date(new Date(v).getTime() + ms)) : v);
const shTiers = (tiers: Tier[]) => tiers.map(t => ({ ...t, deadline: sh(t.deadline) }));
setDraft(d => ({ ...d, startDate: sh(d.startDate), endDate: sh(d.endDate), registrationDeadline: sh(d.registrationDeadline), goLiveAt: sh(d.goLiveAt) }));
setSessions(list => list.map(s => ({ ...s, startsAt: sh(s.startsAt), endsAt: sh(s.endsAt) })));
setOptions(list => list.map(o => ({ ...o, earlyBirdTiers: shTiers(o.earlyBirdTiers), variants: o.variants.map(v => ({ ...v, earlyBirdTiers: shTiers(v.earlyBirdTiers) })) })));
setFbSettings(s => ({ ...s, sendAt: sh(s.sendAt) }));
};
// ── auto-fill first option's price from the event base price (create mode only, until the
// user directly edits that option's price — then it becomes independent) ──
const [mainPriceTouched, setMainPriceTouched] = useState(false);
// user directly edits that option's price — then it becomes independent). A copy keeps its
// own option prices. ──
const [mainPriceTouched, setMainPriceTouched] = useState(cloning);
useEffect(() => {
if (mode !== "create" || mainPriceTouched) return;
setOptions(prev => {
@@ -1323,10 +1410,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
if (cloning) body.clonedFromId = source!.id;
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Groups (and planned costs, for admins) aren't in this form; the server copies them.
if (cloning) {
await apiFetch(`/api/events/${created.id}/copy-from`, { method: "POST", authToken: token || undefined, body: { fromEventId: source!.id } });
}
// Fetch event to get final option IDs for sections
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
@@ -1399,14 +1491,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b">
<h2 className="text-lg font-semibold">{mode === "create" ? "New Event" : "Edit Event"}</h2>
<h2 className="text-lg font-semibold">{cloning ? "Copy Event" : mode === "create" ? "New Event" : "Edit Event"}</h2>
<button type="button" className="text-gray-400 hover:text-gray-700 text-xl leading-none" onClick={onClose}>×</button>
</div>
{/* Step bar */}
<div className="flex items-center gap-1 px-5 py-3 border-b overflow-x-auto">
{STEPS.map((s, i) => {
const clickable = mode === "edit";
const clickable = mode === "edit" || cloning;
return (
<button key={i} type="button"
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
@@ -1462,6 +1554,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{/* ── Step 0: Basic Details ── */}
{step === 0 && (
<div className="space-y-3">
{cloning && (
<div className="p-3 border rounded bg-brand-50 border-brand-200 text-sm space-y-2" data-testid="copy-panel">
<p className="text-gray-700">
A copy of <span className="font-medium">{source?.title}</span> — check each step before saving. Registrations, coupons,
attachments, kids check-in and volunteer roles aren&apos;t copied.
</p>
<DTInput label="New start" value={copyStart} onChange={moveAllDates}
hint={`Moves every date with it: the end${multi ? ", the sessions" : ""}, registration deadline, early-bird deadlines and feedback date.`} />
{copyStart && new Date(copyStart) < new Date() && <p className="text-xs text-amber-700">This copy still starts in the past.</p>}
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Title <span className="text-red-500">*</span></label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.title} onChange={e => upd({ title: e.target.value })} placeholder="Event title" />
@@ -1519,7 +1622,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{campusInfo.scoped ? <option value="" disabled>Choose your campus…</option> : <option value="">All campuses</option>}
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{/* An archived campus stays on events that already have it. */}
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
{base?.campus && !campusInfo.campuses.some(c => c.id === base.campus!.id) && <option value={base.campus.id}>{base.campus.name} (archived)</option>}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">&quot;All campuses&quot; events show at every campus — for church-wide conferences and camps.</p>
</div>
@@ -1882,7 +1985,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ─── event card ───────────────────────────────────────────────────────────────
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
function EventCard({ ev, onEdit, onCopy }: { ev: Event; onEdit: () => void; onCopy: () => void }) {
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
@@ -1902,7 +2005,13 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit →</span>
<div className="flex items-center gap-3 shrink-0 mt-0.5">
<button type="button" title="Make a new event from this one" className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-brand-700"
onClick={e => { e.stopPropagation(); onCopy(); }}>
<Copy className="w-3.5 h-3.5" /> Copy
</button>
<span className="text-xs text-brand-600">Edit →</span>
</div>
</li>
);
}
@@ -1970,6 +2079,19 @@ export default function ManageEventsPage() {
} catch { setEditingEvent(ev); }
setModalMode("edit");
};
// A copy needs the whole source event (options, tiers, form, recipients).
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
const openCopy = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
setEditingEvent(null);
setCopyFrom(full);
setModalMode("create");
} catch (e) {
setError(errorMessage(e) || "Couldn't load the event to copy");
}
};
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
return (
<div className="max-w-4xl mx-auto w-full p-6">
@@ -2010,13 +2132,13 @@ export default function ManageEventsPage() {
<div className="border rounded-xl bg-white shadow-sm p-4">
{filtered.length === 0
? <p className="text-sm text-gray-500">{events.length === 0 ? 'No events yet. Click "+ Add Event" to create one.' : "No events match the current filters."}</p>
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} />)}</ul>
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} onCopy={() => openCopy(ev)} />)}</ul>
}
</div>
{modalMode && (
<EventModal mode={modalMode} event={editingEvent}
onClose={() => { setModalMode(null); setEditingEvent(null); }}
<EventModal mode={modalMode} event={editingEvent} source={copyFrom}
onClose={closeModal}
onSuccess={loadEvents} />
)}
</div>
@@ -0,0 +1,167 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
event: { id: string; title: string; scheduleType: string; kidsCheckIn: boolean; kidsOptionId: string | null; kidsRoomSetId: string | null };
options: { id: string; name: string; price: number }[];
sets: { id: string; name: string; groups: Room[] }[];
};
type SetupEvent = { id: string; title: string; startDate: string; kidsCheckIn: boolean };
const inputCls = "w-full border rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const num = (v: string) => (v.trim() === "" ? null : Number(v));
/** Supervisor → Kids check-in setup: switch it on for an event, choose the kids ticket and the rooms. */
export default function KidsSetupPage() {
const { token } = useAuth();
const [events, setEvents] = useState<SetupEvent[]>([]);
const [eventId, setEventId] = useState("");
const [s, setS] = useState<Settings | null>(null);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<Record<string, { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }>>({});
const [newRoom, setNewRoom] = useState({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" });
useEffect(() => {
if (!token) return;
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
setS(data);
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
}
}, [token, eventId, setMsg]);
useEffect(() => { setS(null); load(); }, [load]);
const run = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
setMsg({ type: "ok", text: ok });
await load();
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
return false;
} finally {
setBusy(false);
}
};
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
const addRoom = () => s?.event.kidsRoomSetId && run(
() => apiFetch(`/api/allocations/sets/${s.event.kidsRoomSetId}/groups`, { method: "POST", authToken: token, body: roomBody(newRoom) }),
"Room added.",
).then(ok => { if (ok) setNewRoom({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" }); });
const saveRoom = (id: string) => run(() => apiFetch(`/api/allocations/groups/${id}`, { method: "PUT", authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiFetch(`/api/allocations/groups/${r.id}`, { method: "DELETE", authToken: token }), "Room deleted.");
const roomRow = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }, set: (v: typeof d) => void) => (
<>
<input aria-label="Room name" className={inputCls} value={d.name} onChange={e => set({ ...d, name: e.target.value })} placeholder="Toddlers" />
<input aria-label="Youngest age" className={inputCls} inputMode="numeric" value={d.minAge} onChange={e => set({ ...d, minAge: e.target.value })} placeholder="from" />
<input aria-label="Oldest age" className={inputCls} inputMode="numeric" value={d.maxAge} onChange={e => set({ ...d, maxAge: e.target.value })} placeholder="to" />
<input aria-label="Capacity" className={inputCls} inputMode="numeric" value={d.capacity} onChange={e => set({ ...d, capacity: e.target.value })} placeholder="—" />
<input aria-label="Children per leader" className={inputCls} inputMode="numeric" value={d.ratio} onChange={e => set({ ...d, ratio: e.target.value })} placeholder="—" />
</>
);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids check-in setup</h1>
<p className="text-sm text-gray-500">Switch kids check-in on for an event and set up its rooms</p>
</div>
</div>
<label className="text-sm block max-w-md">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
</select>
</label>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{s && (
<div className="space-y-4 mt-4">
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
<h2 className="font-semibold">1. Kids ticket</h2>
<p className="text-sm text-gray-600">Each child gets a free place on this ticket the first time they&apos;re checked in, so they appear on the attendance register and reports. Parents don&apos;t buy it.</p>
<div className="flex flex-wrap gap-2">
<select className="border rounded-lg px-3 py-2 text-sm" value={s.event.kidsOptionId || ""} onChange={e => save({ optionId: e.target.value || null })} aria-label="Kids ticket">
<option value="">Choose…</option>
{s.options.map(o => <option key={o.id} value={o.id}>{o.name}{o.price ? ` (R${o.price})` : " (free)"}</option>)}
</select>
{!s.event.kidsOptionId && <button disabled={busy} onClick={() => save({ optionId: "new" }, "Added a free \"Kids check-in\" ticket.")} className="px-3 py-2 rounded-lg border text-sm">Add a free &quot;Kids check-in&quot; ticket</button>}
</div>
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
<h2 className="font-semibold">2. Rooms</h2>
<div className="flex flex-wrap gap-2">
<select className="border rounded-lg px-3 py-2 text-sm" value={s.event.kidsRoomSetId || ""} onChange={e => save({ roomSetId: e.target.value || null })} aria-label="Rooms set">
<option value="">Choose…</option>
{s.sets.map(x => <option key={x.id} value={x.id}>{x.name} ({x.groups.length} rooms)</option>)}
</select>
{!s.event.kidsRoomSetId && <button disabled={busy} onClick={() => save({ roomSetId: "new" }, "Added \"Kids rooms\" — now add the rooms.")} className="px-3 py-2 rounded-lg border text-sm">Add &quot;Kids rooms&quot;</button>}
</div>
{s.event.kidsRoomSetId && (
<div className="overflow-x-auto">
<div className="grid grid-cols-[2fr_repeat(4,minmax(4rem,1fr))_auto] gap-2 items-center min-w-[36rem] text-xs text-gray-500">
<span>Room</span><span>Age from</span><span>Age to</span><span>Capacity</span><span>Per leader</span><span />
{rooms.map(r => draft[r.id] && (
<React.Fragment key={r.id}>
{roomRow(draft[r.id], v => setDraft(d => ({ ...d, [r.id]: v })))}
<span className="flex gap-1">
<button disabled={busy} onClick={() => saveRoom(r.id)} className="px-2 py-1.5 rounded-lg border text-sm text-gray-800">Save</button>
<button disabled={busy} aria-label={`Delete ${r.name}`} onClick={() => deleteRoom(r)} className="p-1.5 rounded-lg border"><Trash2 className="w-4 h-4" /></button>
</span>
</React.Fragment>
))}
{roomRow(newRoom, setNewRoom)}
<button disabled={busy || !newRoom.name.trim()} onClick={addRoom} className="px-2 py-1.5 rounded-lg bg-brand-600 text-white text-sm inline-flex items-center gap-1 disabled:opacity-50"><Plus className="w-4 h-4" /> Add</button>
</div>
<p className="text-xs text-gray-500 mt-2">Ages are whole years (both included). The desk suggests the room with the narrowest age range that fits and isn&apos;t full. &quot;Per leader&quot; is the ratio: the room screen warns when there are more children than the leaders on duty can look after.</p>
</div>
)}
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[14rem]">
<h2 className="font-semibold">3. Switch on</h2>
<p className="text-sm text-gray-600">{s.event.kidsCheckIn ? "Kids check-in is on — staff use Staff → Kids check-in." : "Staff can check children in once this is on."}</p>
</div>
<button disabled={busy} onClick={() => save({ enabled: !s.event.kidsCheckIn }, s.event.kidsCheckIn ? "Kids check-in switched off." : "Kids check-in switched on.")} className={`px-4 py-2 rounded-lg text-sm font-medium ${s.event.kidsCheckIn ? "border" : "bg-brand-600 text-white"}`}>
{s.event.kidsCheckIn ? "Switch off" : "Switch on"}
</button>
{s.event.kidsCheckIn && <Link href="/dashboard/staff/kids" className="px-4 py-2 rounded-lg border text-sm">Open the desk</Link>}
</section>
</div>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
Baby, HandHeart,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +28,9 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
@@ -0,0 +1,154 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { RosterSheet } from "@/components/volunteers/RosterSheet";
import { CheckInQr } from "@/components/volunteers/CheckInQr";
import { HandHeart } from "lucide-react";
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
type Settings = {
event: { id: string; title: string; volunteerOptionId: string | null };
options: { id: string; name: string; price: number }[];
kidsRooms: { id: string; name: string }[];
};
type Tab = "roster" | "qr" | "ticket" | "hours";
const TABS: { id: Tab; label: string }[] = [
{ id: "roster", label: "Roster" },
{ id: "qr", label: "Check-in QR" },
{ id: "ticket", label: "Volunteer ticket" },
{ id: "hours", label: "Hours served" },
];
/** Supervisor → Volunteer rosters: serving roles per event or session, people, check-in QR, volunteer ticket, hours. */
export default function VolunteerRostersPage() {
const { token } = useAuth();
const [events, setEvents] = useState<RosterEvent[]>([]);
const [eventId, setEventId] = useState("");
const [tab, setTab] = useState<Tab>("roster");
const [settings, setSettings] = useState<Settings | null>(null);
const [hours, setHours] = useState<HoursRow[] | null>(null);
const [hoursAll, setHoursAll] = useState(true);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!token) return;
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
.then(list => {
setEvents(list);
const wanted = new URLSearchParams(window.location.search).get("event");
if (wanted && list.some(e => e.id === wanted)) setEventId(wanted);
})
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const loadSettings = useCallback(async () => {
if (!token || !eventId) { setSettings(null); return; }
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
}
}, [token, eventId, setMsg]);
useEffect(() => { loadSettings(); }, [loadSettings]);
useEffect(() => {
if (!token || tab !== "hours") return;
setHours(null);
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
.then(setHours)
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
}, [token, tab, hoursAll, eventId, setMsg]);
const saveTicket = async (optionId: string | null, ok: string) => {
setBusy(true);
try {
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });
} finally {
setBusy(false);
}
};
const event = events.find(e => e.id === eventId);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4 print:hidden">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteer rosters</h1>
<p className="text-sm text-gray-500">Who serves where, for an event or each session</p>
</div>
</div>
<label className="text-sm block max-w-md print:hidden">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
</select>
</label>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
<div className="flex flex-wrap gap-1 mt-4 border-b print:hidden" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)} className={`px-3 py-2 text-sm -mb-px border-b-2 ${tab === t.id ? "border-brand-600 text-brand-700 font-medium" : "border-transparent text-gray-500"}`}>{t.label}</button>
))}
</div>
<div className="mt-4">
{tab !== "hours" && !eventId && <p className="text-sm text-gray-500">Choose an event.</p>}
{tab === "roster" && eventId && (
<p className="text-xs text-gray-500 mb-3 print:hidden">
Sign-up link to share: <span className="font-mono select-all break-all" data-testid="signup-link">{`${typeof window !== "undefined" ? window.location.origin : ""}/dashboard/user/serving?event=${eventId}`}</span>
</p>
)}
{tab === "roster" && eventId && <RosterSheet token={token} eventId={eventId} canEdit kidsRooms={settings?.kidsRooms || []} onMessage={setMsg} />}
{tab === "qr" && eventId && <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />}
{tab === "ticket" && eventId && settings && (
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
<h2 className="font-semibold">Free volunteer ticket</h2>
<p className="text-sm text-gray-600">Off unless you choose a ticket. When it&apos;s on, everyone on this event&apos;s roster gets a free place on it (shown in their My Events), unless they already have a ticket. It&apos;s never offered to the public, and it&apos;s given back when they leave the roster.</p>
<div className="flex flex-wrap gap-2 items-center">
<select aria-label="Volunteer ticket" className="border rounded-lg px-3 py-2 text-sm" value={settings.event.volunteerOptionId || ""} disabled={busy}
onChange={e => saveTicket(e.target.value || null, e.target.value ? "Volunteers get a free place on this ticket." : "No free volunteer ticket.")}>
<option value="">Off — no free ticket</option>
{settings.options.map(o => <option key={o.id} value={o.id}>{o.name}{o.price ? ` (R${o.price})` : " (free)"}</option>)}
</select>
{!settings.event.volunteerOptionId && <button disabled={busy} onClick={() => saveTicket("new", "Added a free \"Volunteer\" ticket — volunteers get a place on it.")} className="px-3 py-2 rounded-lg border text-sm">Add a free &quot;Volunteer&quot; ticket</button>}
</div>
<p className="text-xs text-gray-500">Places already given stay when you switch it off.</p>
</section>
)}
{tab === "hours" && (
<section className="space-y-3">
<div className="flex gap-4 text-sm">
<label className="inline-flex items-center gap-1"><input type="radio" checked={hoursAll} onChange={() => setHoursAll(true)} /> All events</label>
<label className="inline-flex items-center gap-1"><input type="radio" checked={!hoursAll} disabled={!eventId} onChange={() => setHoursAll(false)} /> This event only</label>
</div>
<p className="text-xs text-gray-500">Shifts people checked in for, counted at the shift&apos;s length.</p>
{!hours && <div className="text-sm text-gray-500">Loading…</div>}
{hours && hours.length === 0 && <p className="text-sm text-gray-500">No checked-in shifts yet.</p>}
{hours && hours.length > 0 && (
<table className="w-full text-sm border rounded-xl overflow-hidden bg-white">
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">Volunteer</th><th className="p-2">Shifts</th><th className="p-2">Events</th><th className="p-2">Hours</th></tr></thead>
<tbody>{hours.map(h => <tr key={h.userId} className="border-t"><td className="p-2">{h.name}</td><td className="p-2">{h.shifts}</td><td className="p-2">{h.events}</td><td className="p-2 font-medium">{hoursText(h.hours)}</td></tr>)}</tbody>
</table>
)}
</section>
)}
</div>
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, BellRing, Pencil, Plus, QrCode, Trash2, UserCheck } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { QrImage } from "@/components/shared/QrImage";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { FAMILY_QR_PREFIX, type KidsAdult, type KidsChild, type KidsFamily } from "@/lib/kids";
type ActiveCode = { code: string; qr: string; event: { id: string; title: string }; children: { name: string; room: string | null; checkedInAt: string }[] };
type Mine = { family: KidsFamily | null; qrToken: string | null; codes: ActiveCode[]; pages: { id: string; message: string; createdAt: string }[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
/** My account → Kids: the family's children, authorised adults, family QR and live pickup codes. */
export default function MyKidsPage() {
const { token, user } = useAuth();
const [data, setData] = useState<Mine | null>(null);
const [msg, setMsg] = useDismissingState<Msg>(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [adultForm, setAdultForm] = useState<{ id?: string; name: string; phone: string; relationship: string } | null>(null);
const [familyName, setFamilyName] = useState("");
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/kids/me", { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your family" });
}
}, [token, setMsg]);
useEffect(() => { load(); }, [load]);
// Codes and pages change while children are checked in.
useVisiblePolling(() => { if (data?.codes.length) load(); }, 15000, !!token);
const call = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
setMsg({ type: "ok", text: ok });
await load();
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
return false;
} finally {
setBusy(false);
}
};
const startFamily = () => call(() => apiFetch("/api/kids/me", { method: "POST", authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const saveChild = (child: KidsChild | null, input: ChildInput) => call(
() => apiFetch(child ? `/api/kids/me/children/${child.id}` : "/api/kids/me/children", { method: child ? "PUT" : "POST", authToken: token, body: input }),
child ? "Saved." : "Child added.",
).then(ok => { if (ok) setEditing(null); });
const removeChild = (child: KidsChild) => {
if (!confirm(`Remove ${child.firstName}? Their details are deleted.`)) return;
call(() => apiFetch(`/api/kids/me/children/${child.id}`, { method: "DELETE", authToken: token }), "Removed.");
};
const saveAdult = () => adultForm && call(
() => apiFetch(adultForm.id ? `/api/kids/me/adults/${adultForm.id}` : "/api/kids/me/adults", { method: adultForm.id ? "PUT" : "POST", authToken: token, body: adultForm }),
"Saved.",
).then(ok => { if (ok) setAdultForm(null); });
const removeAdult = (a: KidsAdult) => {
if (!confirm(`Remove ${a.name} from your authorised adults?`)) return;
call(() => apiFetch(`/api/kids/me/adults/${a.id}`, { method: "DELETE", authToken: token }), "Removed.");
};
const share = async (c: ActiveCode) => {
const text = `Pickup code for ${c.children.map(k => k.name.split(" ")[0]).join(", ")} at ${c.event.title}: ${c.code}`;
try {
if (navigator.share) await navigator.share({ text });
else { await navigator.clipboard.writeText(text); setMsg({ type: "ok", text: "Copied — paste it into a message to whoever is collecting." }); }
} catch { /* cancelled */ }
};
const family = data?.family;
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard/user" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids</h1>
<p className="text-sm text-gray-500">Your children for kids check-in, and their pickup codes</p>
</div>
</div>
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{data?.pages.map(p => (
<div key={p.id} className="mb-4 p-4 rounded-xl border-2 border-amber-400 bg-amber-50 flex gap-3 items-start">
<BellRing className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div><div className="font-semibold text-amber-900">{p.message}</div><div className="text-xs text-amber-700">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
</div>
))}
{data?.codes.map(c => (
<section key={c.event.id + c.code} className="mb-4 border-2 border-brand-500 rounded-xl p-4 bg-white shadow-sm">
<div className="text-xs uppercase tracking-wide text-brand-700 font-semibold">Checked in · {c.event.title}</div>
<div className="flex flex-wrap items-center gap-4 mt-2">
<QrImage value={c.qr} size={160} className="w-32 h-32" alt={`Pickup QR code ${c.code}`} />
<div>
<div className="text-xs text-gray-500">Pickup code</div>
<div className="text-5xl font-black tracking-[0.2em] font-mono" data-testid="pickup-code">{c.code}</div>
<ul className="mt-2 text-sm text-gray-700">{c.children.map(k => <li key={k.name}>{k.name}{k.room ? ` — ${k.room}` : ""}</li>)}</ul>
</div>
</div>
<p className="text-xs text-gray-500 mt-3">Show this code or QR to collect your children. Anyone you give the code to can collect them.</p>
<button onClick={() => share(c)} className="mt-2 text-sm px-3 py-1.5 rounded-lg border">Send the code to someone else</button>
</section>
))}
{data && !family && (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold mb-1">Add your family</h2>
<p className="text-sm text-gray-600 mb-3">Add your children once, and the kids desk can check them in quickly every time — scan your family QR code or give your phone number.</p>
<div className="flex gap-2">
<input className={inputCls} placeholder={`${user?.name?.split(" ").slice(-1)[0] || "Smith"} family`} value={familyName} onChange={e => setFamilyName(e.target.value)} />
<button disabled={busy} onClick={startFamily} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium whitespace-nowrap disabled:opacity-50">Add family</button>
</div>
</section>
)}
{family && (
<>
<section className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex items-center justify-between gap-2 mb-3">
<h2 className="font-semibold">{family.name}</h2>
<span className="text-xs text-gray-500">Parents: {family.parents.map(p => p.name).join(", ")}</span>
</div>
<div className="space-y-3">
{family.children.map(c => (
<div key={c.id} className="border rounded-lg p-3">
{editing === c.id ? (
<ChildForm initial={c} custody busy={busy} onSave={v => saveChild(c, v)} onCancel={() => setEditing(null)} />
) : (
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-medium">{c.name}{c.age != null && <span className="text-sm text-gray-500 font-normal"> · {c.age} yrs</span>}</div>
<div className="text-xs text-gray-600 space-x-2">
{c.allergies && <span className="text-red-700">Allergies: {c.allergies}</span>}
{c.hasMedical && <span>Medical notes ✓</span>}
{c.hasCustody && <span>Custody notes ✓</span>}
{!c.allergies && !c.hasMedical && !c.dateOfBirth && <span className="text-amber-700">Add a date of birth so the right room is suggested</span>}
</div>
</div>
<div className="flex gap-1">
<button aria-label={`Edit ${c.firstName}`} onClick={() => setEditing(c.id)} className="p-1.5 rounded hover:bg-gray-100"><Pencil className="w-4 h-4" /></button>
<button aria-label={`Remove ${c.firstName}`} onClick={() => removeChild(c)} className="p-1.5 rounded hover:bg-gray-100"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
)}
</div>
))}
{editing === "new" ? (
<div className="border rounded-lg p-3"><ChildForm custody busy={busy} onSave={v => saveChild(null, v)} onCancel={() => setEditing(null)} saveLabel="Add child" /></div>
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1 text-sm px-3 py-2 rounded-lg border"><Plus className="w-4 h-4" /> Add a child</button>
)}
</div>
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<h2 className="font-semibold flex items-center gap-2"><UserCheck className="w-4 h-4" /> Authorised adults</h2>
<p className="text-sm text-gray-600 mb-3">People who may collect your children (grandparents, friends). They don&apos;t need an account — the room leader sees this list, and the desk can send them the pickup code.</p>
<ul className="space-y-2 mb-3">
{family.adults.map(a => (
<li key={a.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm">
<span>{a.name}{a.relationship ? ` (${a.relationship})` : ""}{a.phone ? ` · ${a.phone}` : ""}</span>
<span className="flex gap-1">
<button aria-label={`Edit ${a.name}`} onClick={() => setAdultForm({ id: a.id, name: a.name, phone: a.phone || "", relationship: a.relationship || "" })} className="p-1.5 rounded hover:bg-gray-100"><Pencil className="w-4 h-4" /></button>
<button aria-label={`Remove ${a.name}`} onClick={() => removeAdult(a)} className="p-1.5 rounded hover:bg-gray-100"><Trash2 className="w-4 h-4" /></button>
</span>
</li>
))}
{!family.adults.length && <li className="text-sm text-gray-500">Nobody yet.</li>}
</ul>
{adultForm ? (
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} placeholder="Name" value={adultForm.name} onChange={e => setAdultForm({ ...adultForm, name: e.target.value })} />
<input className={inputCls} placeholder="Phone" value={adultForm.phone} onChange={e => setAdultForm({ ...adultForm, phone: e.target.value })} />
<input className={inputCls} placeholder="Relationship (e.g. Gran)" value={adultForm.relationship} onChange={e => setAdultForm({ ...adultForm, relationship: e.target.value })} />
<div className="sm:col-span-3 flex gap-2">
<button disabled={busy} onClick={saveAdult} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium disabled:opacity-50">Save</button>
<button onClick={() => setAdultForm(null)} className="px-4 py-2 rounded-lg border text-sm">Cancel</button>
</div>
</div>
) : (
<button onClick={() => setAdultForm({ name: "", phone: "", relationship: "" })} className="inline-flex items-center gap-1 text-sm px-3 py-2 rounded-lg border"><Plus className="w-4 h-4" /> Add an adult</button>
)}
</section>
{data?.qrToken && (
<section className="border rounded-xl p-4 bg-white shadow-sm flex flex-wrap items-center gap-4">
<QrImage value={FAMILY_QR_PREFIX + data.qrToken} size={160} className="w-32 h-32" alt="Family QR code" />
<div className="text-sm text-gray-700 max-w-sm">
<div className="font-semibold flex items-center gap-2"><QrCode className="w-4 h-4" /> Family QR code</div>
Show this at the kids desk to find your family quickly. It only finds your family — it doesn&apos;t collect children; that needs the pickup code.
</div>
</section>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BackLink } from "@/components/shared/BackLink";
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const btn = "px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50";
/** My account → Serving: my shifts, cover requests I could take, and events looking for volunteers. */
export default function ServingPage() {
const { token } = useAuth();
const [data, setData] = useState<Mine | null>(null);
const [msg, setMsg] = useDismissingState<Msg>(null);
const [busy, setBusy] = useState(false);
const [openEvent, setOpenEvent] = useState<string>("");
const [roster, setRoster] = useState<Roster | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
}
}, [token, setMsg]);
useEffect(() => { load(); }, [load]);
// ?event=<id>: the sign-up link a supervisor shares (works for hidden events too).
useEffect(() => {
const wanted = new URLSearchParams(window.location.search).get("event");
if (wanted) setOpenEvent(wanted);
}, []);
const loadRoster = useCallback(async (id: string) => {
if (!token || !id) { setRoster(null); return; }
try {
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
}
}, [token, setMsg]);
useEffect(() => { loadRoster(openEvent); }, [openEvent, loadRoster]);
const call = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
setMsg({ type: "ok", text: ok });
await Promise.all([load(), openEvent ? loadRoster(openEvent) : null]);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
} finally {
setBusy(false);
}
};
const askCover = (s: MySlot) => {
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
if (note === null) return;
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
};
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
const upcoming = (data?.slots || []).filter(s => !s.past);
const past = (data?.slots || []).filter(s => s.past);
return (
<div className="max-w-3xl mx-auto w-full p-6 space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard/user" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Serving</h1>
<p className="text-sm text-gray-500">Your volunteer shifts, and events looking for people to serve</p>
</div>
</div>
{msg && <div role="status" className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{!data && <div className="text-sm text-gray-500">Loading…</div>}
{data && (
<section className="space-y-3">
<h2 className="font-semibold">My shifts</h2>
{upcoming.length === 0 && <p className="text-sm text-gray-500">You&apos;re not on any roster yet. Sign up for a role below.</p>}
{upcoming.map(s => (
<div key={s.slotId} className="border rounded-xl p-4 bg-white shadow-sm" data-testid="my-shift">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="font-semibold">{s.role.name} <span className="font-normal text-gray-500">· {s.event.title}</span></div>
<div className="text-sm text-gray-600">{s.role.sessionLabel ? `${s.role.sessionLabel} · ` : ""}{s.shift.text}{s.event.location ? ` · ${s.event.location}` : ""}</div>
{s.role.kidsRoom && <div className="text-xs text-gray-500">Kids room: {s.role.kidsRoom}</div>}
{s.role.description && <div className="text-xs text-gray-500 mt-1">{s.role.description}</div>}
{s.hasTicket && <div className="text-xs text-gray-500 mt-1">Your free volunteer ticket is in My Events.</div>}
</div>
{s.checkedInAt
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> Checked in</span>
: s.swapId && <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">Cover requested</span>}
</div>
{s.canCheckIn && <p className="mt-2 text-sm text-brand-700 flex items-center gap-1"><QrCode className="w-4 h-4" /> When you arrive, scan the volunteer check-in QR code with your phone&apos;s camera.</p>}
{!s.checkedInAt && (
<div className="mt-3 flex flex-wrap gap-2">
{s.swapId
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can&apos;t make it</button>}
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
</div>
)}
</div>
))}
{past.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500">Last week ({past.length})</summary>
<ul className="mt-1 space-y-0.5 text-gray-600">
{past.map(s => <li key={s.slotId}>{s.role.name} · {s.event.title} · {s.shift.text}{s.checkedInAt ? " · served" : ""}</li>)}
</ul>
</details>
)}
</section>
)}
{data && data.cover.length > 0 && (
<section className="space-y-3">
<h2 className="font-semibold">Can you take over?</h2>
{data.cover.map(c => (
<div key={c.swapId} className="border border-amber-300 rounded-xl p-4 bg-amber-50 flex flex-wrap items-center justify-between gap-3" data-testid="cover-request">
<div>
<div className="font-semibold">{c.role.name} <span className="font-normal text-gray-600">· {c.event.title}</span></div>
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can&apos;t make it{c.note ? `: “${c.note}”` : ""}</div>
</div>
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
</div>
))}
</section>
)}
{data && (
<section className="space-y-3">
<h2 className="font-semibold">Serve at an event</h2>
{data.events.length === 0 && !openEvent && <p className="text-sm text-gray-500">No events are looking for volunteers right now.</p>}
{(data.events.length > 0 || openEvent) && (
<label className="text-sm block max-w-md">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={openEvent} onChange={e => setOpenEvent(e.target.value)}>
<option value="">Choose an event…</option>
{roster && !data.events.some(e => e.id === roster.event.id) && <option value={roster.event.id}>{roster.event.title} · {formatDate(new Date(roster.event.startDate))}</option>}
{data.events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))} ({e.roles} role{e.roles === 1 ? "" : "s"})</option>)}
</select>
</label>
)}
{roster && roster.event.id === openEvent && (
<div className="space-y-2">
{roster.roles.filter(r => !r.past).map(r => (
<div key={r.id} className="border rounded-xl p-3 bg-white flex flex-wrap items-center justify-between gap-3" data-testid="open-role">
<div>
<div className="font-medium">{r.name}{r.kidsRoom ? <span className="text-xs text-gray-500"> · kids room {r.kidsRoom.name}</span> : null}</div>
<div className="text-sm text-gray-600">{r.sessionLabel ? `${r.sessionLabel} · ` : ""}{r.shift.text}</div>
{r.description && <div className="text-xs text-gray-500">{r.description}</div>}
<div className="text-xs text-gray-500">{r.filled} of {r.needed} signed up</div>
</div>
{r.mine
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You&apos;re serving</span>
: r.coverNeeded.length > 0
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
: r.full
? <span className="text-sm text-gray-500">Full</span>
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
</div>
))}
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
</div>
)}
</section>
)}
</div>
);
}
+27 -16
View File
@@ -14,17 +14,18 @@ const sections = [
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "others", title: "6. Registering Other People" },
{ id: "youth", title: "7. Youth Events & Parental Consent" },
{ id: "transfers", title: "8. Ticket Transfers" },
{ id: "extras", title: "9. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "10. Acceptable Use" },
{ id: "communication", title: "11. Communication" },
{ id: "support", title: "12. Account Security & Support Access" },
{ id: "intellectual", title: "13. Intellectual Property" },
{ id: "liability", title: "14. Limitation of Liability" },
{ id: "forcemajeure", title: "15. Force Majeure" },
{ id: "termination", title: "16. Termination of Access" },
{ id: "law", title: "17. Governing Law" },
{ id: "contact", title: "18. Contact" },
{ id: "kids", title: "8. Kids Check-in" },
{ id: "transfers", title: "9. Ticket Transfers" },
{ id: "extras", title: "10. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "11. Acceptable Use" },
{ id: "communication", title: "12. Communication" },
{ id: "support", title: "13. Account Security & Support Access" },
{ id: "intellectual", title: "14. Intellectual Property" },
{ id: "liability", title: "15. Limitation of Liability" },
{ id: "forcemajeure", title: "16. Force Majeure" },
{ id: "termination", title: "17. Termination of Access" },
{ id: "law", title: "18. Governing Law" },
{ id: "contact", title: "19. Contact" },
];
export default function TermsOfUsePage() {
@@ -96,6 +97,7 @@ export default function TermsOfUsePage() {
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
<li>For youth events, a parent or guardian must give consent before a young person can be checked in.</li>
<li>At kids check-in, anyone with your children&apos;s pickup code can collect them — keep it private and only share it with people you trust.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
@@ -128,7 +130,8 @@ function Section({
purpose: [
`The platform is designed to:`,
`• Allow users to view, register, and pay for events hosted or co-hosted by ${orgName}.`,
`• Manage event registration details, communication, and participation records.`,
`• Manage event registration details, communication, check-in and attendance records, including completion certificates for courses.`,
`• Check children in and out of children's ministry (kids check-in).`,
`The site may also provide updates or communication related to events.`,
],
accounts: [
@@ -151,7 +154,8 @@ function Section({
registrations: [
"Each ticket has a QR code that is checked at the event. Keep it private — anyone holding a copy of it may be able to use it.",
"A ticket can only be used for the number of places it covers. Once a ticket has been scanned it cannot be transferred.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 8). You may not sell tickets for profit.",
"On events with several sessions, a ticket is checked in once per session it covers. Where an event issues completion certificates, the organisers decide the attendance needed, and may withdraw a certificate issued in error.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 9). You may not sell tickets for profit.",
"At some events, door staff may check tickets against a list downloaded to their device in case the internet connection fails. If a ticket was bought or changed after that list was downloaded, you may be asked to go to the door desk.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
@@ -168,6 +172,13 @@ function Section({
"The parent or guardian is responsible for the accuracy of any medical and emergency information they provide, and for telling the organisers about changes.",
"A supervisor may in exceptional cases check someone in without recorded consent; such decisions are logged.",
],
kids: [
"At events with kids check-in, children are checked in and out at a desk run by the organisers' staff. You can add your children and the adults allowed to collect them under My account → Kids, or at the desk.",
"When your children are checked in, you receive a pickup code. Anyone who shows that code (or its QR code) may collect the children checked in with it. You are responsible for keeping the code private and for whom you share it with. Without the code, staff may release a child only after checking who is collecting and recording the reason.",
"You are responsible for the accuracy of your children's details — especially allergies, medical notes and custody notes — and for telling the organisers about changes. Staff rely on what you provide.",
"Staff may contact you during the event (for example, to ask you to come to your child's room). Please make sure your phone number is correct.",
"You remain responsible for your children before check-in and after they have been collected.",
],
transfers: [
"Where an event allows it, the person who booked a ticket or the person currently holding it can offer the ticket to someone else before the event starts, provided the ticket has not been scanned. Supervisors may transfer tickets even where an event does not otherwise allow it.",
"You enter the new holder's name and email address and/or phone number. We send them a link to accept the ticket. Nothing changes until they accept; they may also decline, and you may cancel the offer until then. Offers expire after 7 days or when the event starts.",
@@ -194,7 +205,7 @@ function Section({
communication: [
"By registering, you consent to receive communications related to your registrations, payments, tickets, and account security.",
"Depending on your notification preference, these may be delivered via email and/or WhatsApp. You can update your preference at any time from the Profile & Security page in your account.",
"Certain communications are required for the operation of the platform and cannot be opted out of entirely — these include payment confirmations, ticket delivery, and account security alerts (e.g. new login notifications, password resets, and account closure confirmations). Security alerts are always sent via email; WhatsApp delivery is additional when you have opted in.",
"Certain communications are required for the operation of the platform and cannot be opted out of entirely — these include payment confirmations, ticket delivery, kids check-in pickup codes and requests to come to your child's room, and account security alerts (e.g. new login notifications, password resets, and account closure confirmations). Security alerts are always sent via email; WhatsApp delivery is additional when you have opted in.",
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
@@ -212,7 +223,7 @@ function Section({
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 15 (Force Majeure).",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 16 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
@@ -220,7 +231,7 @@ function Section({
],
termination: [
"We may suspend or terminate access to your account if we believe you have violated these Terms of Use or engaged in behaviour harmful to others or the organisation.",
`You may delete your own account at any time from the Profile & Security page in your account, or by contacting ${orgEmail}. Upon deletion, your personal data will be handled in accordance with our Privacy Policy.`,
`You may delete your own account at any time from the Privacy or Profile & Security page in your account, or by contacting ${orgEmail}. Upon deletion, your personal data will be handled in accordance with our Privacy Policy.`,
"Certain data may be retained for legal or financial record-keeping purposes even after account deletion.",
],
law: [
+74
View File
@@ -0,0 +1,74 @@
"use client";
import React, { Suspense, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { CheckCircle2, HandHeart } from "lucide-react";
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
/** Where the volunteer check-in QR code leads: checks the signed-in volunteer in for today's shifts. */
function CheckIn() {
const code = useSearchParams().get("code") || "";
const { user, token, loading } = useAuth();
const [result, setResult] = useState<Result | null>(null);
const [error, setError] = useState<string | null>(code ? null : "This check-in link is incomplete — scan the QR code again.");
const sent = useRef(false);
useEffect(() => {
if (!code || loading || !token || sent.current) return;
sent.current = true;
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
.then(setResult)
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
}, [code, loading, token]);
if (error) return <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm" role="alert">{error} <Link href="/dashboard/user/serving" className="underline">My shifts</Link></div>;
if (loading) return <div className="text-sm text-gray-600">Loading…</div>;
if (!user) {
const back = `/serve/check-in?code=${encodeURIComponent(code)}`;
return (
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
<p className="text-sm text-gray-700">Log in to check in for your volunteer shift.</p>
<Link href={`/login?redirect=${encodeURIComponent(back)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Log in</Link>
</div>
);
}
if (!result) return <div className="text-sm text-gray-600">Checking you in…</div>;
return (
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3" data-testid="volunteer-checked-in">
<div className="flex items-center gap-2 text-green-700 font-semibold"><CheckCircle2 className="w-6 h-6" /> Thank you for serving, {user.name?.split(" ")[0]}!</div>
<p className="text-sm text-gray-700">You&apos;re checked in at <strong>{result.event.title}</strong>:</p>
<ul className="text-sm text-gray-800 list-disc pl-5">
{result.checkedIn.map(c => <li key={c.role}>{c.role}{c.kidsRoom ? ` — you're on duty in ${c.kidsRoom}` : ""}</li>)}
{result.already.map(r => <li key={r}>{r} (already checked in)</li>)}
</ul>
<Link href="/dashboard/user/serving" className="text-sm text-brand-600 hover:underline">My shifts</Link>
</div>
);
}
export default function VolunteerCheckInPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<HandHeart className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Volunteer check-in</h1>
</div>
<Suspense fallback={<div className="text-sm text-gray-600">Loading…</div>}>
<CheckIn />
</Suspense>
</main>
<Footer />
</div>
);
}
+109 -16
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
@@ -21,9 +21,39 @@ export type HubStatus = {
version: string;
latestVersion: string | null;
updateAvailable: boolean;
urgentUpdate: boolean;
updateSnoozed: boolean;
releases: HubRelease[];
updateRequest: { requestedAt: string; version: string } | null;
notices: HubNotice[];
};
export type HubRelease = { version: string; releasedAt: string | null; urgent: boolean; notes: string };
export type HubNotice = { id: string; level: "info" | "maintenance" | "warning"; title: string; message: string; startsAt: string | null; endsAt: string | null; updatedAt: string | null };
/** Release notes from the provider: lines starting with "- " are a list, the rest paragraphs. */
export function ReleaseNotes({ notes }: { notes: string }) {
const blocks: ({ list: false; text: string } | { list: true; items: string[] })[] = [];
for (const raw of notes.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const item = /^[-*•]\s+(.*)$/.exec(line);
const last = blocks[blocks.length - 1];
if (item) {
if (last?.list) last.items.push(item[1]);
else blocks.push({ list: true, items: [item[1]] });
} else blocks.push({ list: false, text: line });
}
if (!blocks.length) return <p className="text-gray-400 italic">No details given.</p>;
return (
<div className="space-y-1.5 text-gray-700">
{blocks.map((b, i) => (b.list
? <ul key={i} className="list-disc pl-5 space-y-0.5">{b.items.map((t, j) => <li key={j}>{t}</li>)}</ul>
: <p key={i}>{b.text}</p>))}
</div>
);
}
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
@@ -94,18 +124,25 @@ export function LicenceTab({ active }: { active: boolean }) {
}
};
const requestUpdate = async () => {
// Request, withdraw, "Not now" and "remind me again" all return the new status.
const updateAction = async (path: string, body: unknown, ok: string) => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
} finally {
setBusy(false);
}
};
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
const withdrawRequest = () => {
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
};
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -162,22 +199,78 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
<p>This site runs Cross Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (status.updateRequest ? (
<p className="mt-1 text-gray-600">
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
</p>
) : (
<div className="mt-2 flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
</button>
{status.updateAvailable && (
<div className="mt-2 space-y-3">
{status.urgentUpdate && (
<p className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
<span>This update fixes a security problem or a serious bug. Please ask for it soon.</span>
</p>
)}
{status.updateRequest ? (
<div className="flex flex-wrap items-center gap-3">
<p className="text-gray-600">Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.</p>
{status.connected && (
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<Undo2 className="w-4 h-4" /> Withdraw request
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.{status.updateSnoozed && <span className="text-gray-500"> You chose &quot;Not now&quot;, so there are no reminders until the next version.</span>}</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : "Request update"}
</button>
)}
{!status.urgentUpdate && (status.updateSnoozed ? (
<button onClick={() => snooze(false)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellRing className="w-4 h-4" /> Remind me again
</button>
) : (
<button onClick={() => snooze(true)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellOff className="w-4 h-4" /> Not now
</button>
))}
</div>
)}
{status.releases.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-gray-900 mb-2">What&apos;s new</h3>
<div className="space-y-4">
{status.releases.map(r => (
<div key={r.version}>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-semibold">{r.version}</span>
{r.urgent && <span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 px-2 py-0.5 text-xs font-medium text-red-700"><ShieldAlert className="w-3 h-3" /> Security or urgent fix</span>}
{r.releasedAt && <span className="text-xs text-gray-500">{new Date(r.releasedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>}
</div>
<ReleaseNotes notes={r.notes} />
</div>
))}
</div>
</div>
)}
</div>
))}
)}
</div>
{status.notices.length > 0 && (
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-2"><Megaphone className="w-4 h-4" /> From your provider</div>
<div className="space-y-3">
{status.notices.map(n => (
<div key={n.id}>
<p className="font-medium">{n.title}</p>
<p className="text-gray-700 whitespace-pre-line">{n.message}</p>
{n.endsAt && <p className="text-xs text-gray-500 mt-0.5">Until {fmt(n.endsAt)}</p>}
</div>
))}
</div>
</div>
)}
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
</div>
);
@@ -0,0 +1,67 @@
"use client";
import React, { useState } from "react";
import type { KidsChild } from "@/lib/kids";
export type ChildInput = {
firstName: string; lastName: string; dateOfBirth: string; gender: string;
allergies: string; medicalNotes: string; custodyNotes?: string;
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
export function childToInput(c?: KidsChild | null): ChildInput {
return {
firstName: c?.firstName || "", lastName: c?.lastName || "",
dateOfBirth: c?.dateOfBirth ? c.dateOfBirth.slice(0, 10) : "", gender: c?.gender || "",
allergies: c?.allergies || "", medicalNotes: c?.medicalNotes || "", custodyNotes: c?.custodyNotes || "",
};
}
type Props = {
initial?: KidsChild | null;
/** Show the custody notes box (parents, supervisors and admins). */
custody: boolean;
busy?: boolean;
onSave: (input: ChildInput) => void;
onCancel?: () => void;
saveLabel?: string;
};
/** Add or change a child: name, date of birth, allergies, medical and (optionally) custody notes. */
export function ChildForm({ initial, custody, busy, onSave, onCancel, saveLabel = "Save" }: Props) {
const [v, setV] = useState<ChildInput>(() => childToInput(initial));
const set = (k: keyof ChildInput) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => setV(prev => ({ ...prev, [k]: e.target.value }));
return (
<form
className="space-y-3"
onSubmit={e => {
e.preventDefault();
const out = { ...v };
if (!custody) delete out.custodyNotes;
onSave(out);
}}
>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm"><span className="block text-xs text-gray-500 mb-1">First name *</span><input className={inputCls} value={v.firstName} onChange={set("firstName")} required maxLength={60} /></label>
<label className="text-sm"><span className="block text-xs text-gray-500 mb-1">Surname</span><input className={inputCls} value={v.lastName} onChange={set("lastName")} maxLength={60} /></label>
<label className="text-sm"><span className="block text-xs text-gray-500 mb-1">Date of birth</span><input type="date" className={inputCls} value={v.dateOfBirth} onChange={set("dateOfBirth")} max={new Date().toISOString().slice(0, 10)} /></label>
<label className="text-sm"><span className="block text-xs text-gray-500 mb-1">Gender</span>
<select className={inputCls} value={v.gender} onChange={set("gender")}>
<option value="">—</option><option value="female">Girl</option><option value="male">Boy</option>
</select>
</label>
</div>
<label className="block text-sm"><span className="block text-xs text-gray-500 mb-1">Allergies</span><input className={inputCls} value={v.allergies} onChange={set("allergies")} maxLength={300} placeholder="e.g. Peanuts, bee stings" /></label>
<label className="block text-sm"><span className="block text-xs text-gray-500 mb-1">Medical notes</span><textarea className={inputCls} rows={2} value={v.medicalNotes} onChange={set("medicalNotes")} maxLength={1000} placeholder="Conditions, medicine, what leaders should know" /></label>
{custody && (
<label className="block text-sm"><span className="block text-xs text-gray-500 mb-1">Custody notes (room leaders only)</span><textarea className={inputCls} rows={2} value={v.custodyNotes} onChange={set("custodyNotes")} maxLength={1000} placeholder='e.g. "Do not release to …"' /></label>
)}
<p className="text-xs text-gray-500">Allergies, medical and custody notes are stored encrypted and only shown to kids-ministry staff. They are never sent in messages.</p>
<div className="flex gap-2">
<button type="submit" disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium disabled:opacity-50">{busy ? "Saving…" : saveLabel}</button>
{onCancel && <button type="button" onClick={onCancel} className="px-4 py-2 rounded-lg border text-sm">Cancel</button>}
</div>
</form>
);
}
@@ -0,0 +1,67 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import type { KidsEvent } from "@/lib/kids";
type Props = {
token: string | null;
eventId: string;
sessionId: string;
onChange: (eventId: string, sessionId: string) => void;
};
const STORAGE = "hope_events_kids_event";
/** Picks the kids event (and session) the desk or room screen works on; remembers the choice. */
export function KidsEventPicker({ token, eventId, sessionId, onChange }: Props) {
const [events, setEvents] = useState<KidsEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<KidsEvent[]>("/api/kids/events", { authToken: token })
.then(list => {
setEvents(list);
if (eventId || !list.length) return;
let remembered = "";
try { remembered = localStorage.getItem(STORAGE) || ""; } catch { /* ignore */ }
const now = Date.now();
const current = list.find(e => e.id === remembered) || list.find(e => new Date(e.endDate).getTime() >= now) || list[0];
onChange(current.id, "");
})
.catch(e => setError(e?.message || "Could not load events"));
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
const event = events?.find(e => e.id === eventId);
const pick = (id: string, sid = "") => {
try { localStorage.setItem(STORAGE, id); } catch { /* ignore */ }
onChange(id, sid);
};
if (error) return <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>;
if (events && !events.length) {
return <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have kids check-in switched on. A supervisor sets it up under Supervisor → Kids check-in setup.</div>;
}
return (
<div className="flex flex-wrap gap-2 items-end">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => pick(e.target.value)}>
{!eventId && <option value="">Choose an event…</option>}
{events?.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
</select>
</label>
{event?.scheduleType === "sessions" && (
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Session</span>
<select className="border rounded-lg px-3 py-2 text-sm" value={sessionId} onChange={e => pick(event.id, e.target.value)}>
<option value="">Today&apos;s session</option>
{event.sessions.map((s, i) => <option key={s.id} value={s.id}>{s.label || `Session ${i + 1}`} · {formatDate(new Date(s.startsAt))}</option>)}
</select>
</label>
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import React, { useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { AlertTriangle, BellRing, HeartPulse, LogOut, ShieldAlert, Users } from "lucide-react";
import { ageRange, type KidsBoard, type KidsCheckIn, type KidsRoom } from "@/lib/kids";
type Props = {
board: KidsBoard;
token: string | null;
/** Only this room ('' = every room). */
roomId: string;
onChanged: () => void;
onMessage: (m: { type: "ok" | "err"; text: string }) => void;
};
const time = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
export function RoomStatusBadges({ room }: { room: KidsRoom }) {
return (
<span className="inline-flex flex-wrap gap-1 text-xs">
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">{room.children}{room.capacity ? ` / ${room.capacity}` : ""} in</span>
<span className={`px-2 py-0.5 rounded-full ${room.overRatio ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-700"}`}>
{room.leaders} leader{room.leaders === 1 ? "" : "s"}{room.ratio ? ` (needs ${room.leadersNeeded})` : ""}
</span>
{room.full && <span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">Full</span>}
{room.overRatio && <span className="px-2 py-0.5 rounded-full bg-red-100 text-red-800">Over ratio</span>}
</span>
);
}
/** Live cards of the children in the rooms, with paging, moving and checking out without the code. */
export function RoomBoard({ board, token, roomId, onChanged, onMessage }: Props) {
const [busy, setBusy] = useState<string | null>(null);
const rooms = board.rooms.filter(r => !roomId || r.id === roomId);
const alerts = board.alerts.filter(a => !a.resolvedAt && a.kind === "wrong_code" && (!roomId || !a.roomId || a.roomId === roomId));
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(id);
try {
await fn();
onMessage({ type: "ok", text: ok });
onChanged();
} catch (e) {
onMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
} finally {
setBusy(null);
}
};
const page = (c: KidsCheckIn) => {
const extra = prompt(`Message the parents of ${c.firstName}? They'll be asked to come to the room. Add a note (optional):`, "");
if (extra === null) return;
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/page`, { method: "POST", authToken: token, body: { message: extra } }), `Message sent to ${c.firstName}'s parents.`);
};
const override = (c: KidsCheckIn) => {
const who = prompt(`Who is collecting ${c.firstName}?`, c.family?.parents[0]?.name || "");
if (!who) return;
const reason = prompt("Why without the pickup code? (recorded in the audit log)", "");
if (!reason) return;
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/override`, { method: "POST", authToken: token, body: { collectedBy: who, reason } }), `${c.firstName} checked out.`);
};
const move = (c: KidsCheckIn, to: string) => act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/room`, { method: "PUT", authToken: token, body: { roomId: to } }), `${c.firstName} moved.`);
const resolve = (id: string) => act(id, () => apiFetch(`/api/kids/alerts/${id}/resolve`, { method: "POST", authToken: token }), "Alert cleared.");
return (
<div className="space-y-4">
{alerts.map(a => (
<div key={a.id} role="alert" className="p-3 rounded-xl border-2 border-red-500 bg-red-50 flex items-start justify-between gap-3">
<div className="flex gap-2"><ShieldAlert className="w-5 h-5 text-red-600 shrink-0" />
<div><div className="font-semibold text-red-900">{a.message}</div><div className="text-xs text-red-700">{time(a.at)}{a.by ? ` · ${a.by}` : ""} — check who is collecting before handing over any child.</div></div>
</div>
<button disabled={busy === a.id} onClick={() => resolve(a.id)} className="text-sm px-3 py-1 rounded-lg border border-red-300 bg-white">Dealt with</button>
</div>
))}
{rooms.map(r => {
const inRoom = board.checkIns.filter(c => c.roomId === r.id && !c.checkedOutAt);
const out = board.checkIns.filter(c => c.roomId === r.id && c.checkedOutAt);
return (
<section key={r.id} className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h2 className="font-semibold text-lg">{r.name} <span className="text-sm font-normal text-gray-500">{ageRange(r)}</span></h2>
<RoomStatusBadges room={r} />
</div>
{r.leaderNames.length > 0 && <div className="text-xs text-gray-500 mb-2 flex items-center gap-1"><Users className="w-3.5 h-3.5" /> On duty: {r.leaderNames.join(", ")}</div>}
{(r.rostered || []).some(v => !v.checkedIn) && <div className="text-xs text-gray-500 mb-2">Rostered, not checked in yet: {r.rostered.filter(v => !v.checkedIn).map(v => v.name).join(", ")}</div>}
{inRoom.length === 0 && <p className="text-sm text-gray-500">No children in this room.</p>}
<div className="grid sm:grid-cols-2 gap-3">
{inRoom.map(c => (
<div key={c.id} className={`border rounded-lg p-3 ${c.custodyNotes ? "border-red-400" : c.allergies || c.medicalNotes ? "border-amber-400" : ""}`} data-testid="kids-card">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold">{c.name}{c.age != null && <span className="text-sm font-normal text-gray-500"> · {c.age}</span>}</div>
<div className="text-xs text-gray-500">In {time(c.checkedInAt)} · code <span className="font-mono font-semibold text-gray-800">{c.code}</span>{c.offline ? " · offline" : ""}</div>
</div>
</div>
{c.allergies && <div className="mt-1 text-sm text-red-800 flex gap-1"><AlertTriangle className="w-4 h-4 shrink-0" /> Allergies: {c.allergies}</div>}
{c.medicalNotes && <div className="mt-1 text-sm text-amber-800 flex gap-1"><HeartPulse className="w-4 h-4 shrink-0" /> {c.medicalNotes}</div>}
{c.custodyNotes && <div className="mt-1 text-sm text-red-900 bg-red-50 rounded px-2 py-1 flex gap-1"><ShieldAlert className="w-4 h-4 shrink-0" /> {c.custodyNotes}</div>}
{c.family && (
<div className="mt-2 text-xs text-gray-600">
{c.family.parents.map(p => <div key={p.userId}>{p.name}{p.phone ? ` · ${p.phone}` : ""}</div>)}
{c.family.adults.length > 0 && <div>May also collect: {c.family.adults.map(a => `${a.name}${a.relationship ? ` (${a.relationship})` : ""}`).join(", ")}</div>}
</div>
)}
<div className="mt-2 flex flex-wrap gap-1.5">
<button disabled={busy === c.id} onClick={() => page(c)} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-lg border"><BellRing className="w-3.5 h-3.5" /> Call parent</button>
<select aria-label={`Move ${c.firstName}`} value="" onChange={e => e.target.value && move(c, e.target.value)} className="text-xs border rounded-lg px-2 py-1">
<option value="">Move to…</option>
{board.rooms.filter(o => o.id !== r.id).map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
<button disabled={busy === c.id} onClick={() => override(c)} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-lg border text-gray-600"><LogOut className="w-3.5 h-3.5" /> Without code</button>
</div>
</div>
))}
</div>
{out.length > 0 && (
<details className="mt-3 text-sm">
<summary className="cursor-pointer text-gray-500">Collected ({out.length})</summary>
<ul className="mt-1 space-y-0.5 text-xs text-gray-600">
{out.map(c => <li key={c.id}>{c.name} — {time(c.checkedOutAt!)} by {c.collectedBy || "—"}{c.override ? " (without code)" : ""}</li>)}
</ul>
</details>
)}
</section>
);
})}
{!roomId && board.checkIns.some(c => !c.roomId && !c.checkedOutAt) && (
<section className="border border-amber-300 rounded-xl p-4 bg-amber-50">
<h2 className="font-semibold mb-2">No room</h2>
<p className="text-xs text-amber-800 mb-2">Their room was deleted — choose a new one.</p>
<ul className="space-y-1 text-sm">
{board.checkIns.filter(c => !c.roomId && !c.checkedOutAt).map(c => (
<li key={c.id} className="flex items-center gap-2">
<span>{c.name}</span>
<select aria-label={`Move ${c.firstName}`} value="" onChange={e => e.target.value && move(c, e.target.value)} className="text-xs border rounded-lg px-2 py-1 bg-white">
<option value="">Move to…</option>
{board.rooms.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</li>
))}
</ul>
</section>
)}
</div>
);
}
@@ -0,0 +1,37 @@
import React from "react";
type Props = {
code: string;
/** Checked in on an offline desk: nothing was sent to the parents. */
offline?: boolean;
kids: { id: string; firstName: string; room: string | null; sticker: string; already?: boolean }[];
};
/**
* "Write on the sticker": what volunteers copy onto a blank name sticker for each child — first
* name, room, pickup code and a short allergy/medical alert — in print big enough to copy at a
* glance. Nothing is printed; the full details stay on screen.
*/
export function StickerPanel({ code, kids, offline = false }: Props) {
return (
<div>
<div className="text-center mb-3">
<div className="text-xs uppercase tracking-wide text-gray-500">Pickup code</div>
<div className="text-6xl font-black font-mono tracking-[0.25em]" data-testid="desk-code">{code}</div>
<div className="text-xs text-gray-500">{offline ? "Offline: nothing was sent — tell the parent the code." : "Sent to the parents. Tell them too, in case the message is slow."}</div>
</div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-2">Write on the sticker</div>
<div className="grid sm:grid-cols-2 gap-3">
{kids.map(c => (
<div key={c.id} className="border-2 border-dashed border-gray-400 rounded-xl p-4 bg-white" data-testid="sticker">
<div className="text-3xl font-bold leading-tight">{c.firstName}</div>
<div className="text-lg text-gray-700">{c.room || "—"}</div>
<div className="mt-1 text-2xl font-mono font-bold tracking-widest">{code}</div>
{c.sticker && <div className="mt-1 text-lg font-bold text-red-700">{c.sticker}</div>}
{c.already && <div className="mt-1 text-xs text-gray-500">Was already checked in.</div>}
</div>
))}
</div>
</div>
);
}
+3 -1
View File
@@ -2,7 +2,7 @@
import React, { useState } from "react";
import Link from "next/link";
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, type LucideIcon } from "lucide-react";
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, Baby, HandHeart, type LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
import { UserAvatar } from "@/components/shared/UserAvatar";
@@ -12,6 +12,8 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/kids", label: "Kids", icon: Baby, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/serving", label: "Serving", icon: HandHeart, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/privacy", label: "Privacy", icon: Lock, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
@@ -2,18 +2,20 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle } from "lucide-react";
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
* public site): grace period, suspended/revoked, not connected, update available.
* public site): grace period, suspended/revoked, not connected, update available (with "Not now").
* The provider's own notices are ProviderNotices.
*/
export function LicenceBanner() {
const { user, token } = useAuth();
const [status, setStatus] = useState<HubStatus | null>(null);
const [busy, setBusy] = useState(false);
const isAdmin = user?.role === "admin";
useEffect(() => {
@@ -50,12 +52,31 @@ export function LicenceBanner() {
</div>
);
}
// Once an update has been requested there's nothing more for the admin to do.
if (status.updateAvailable && !status.updateRequest) {
// Once an update has been requested there's nothing more for the admin to do. "Not now" hides it
// until the next version, except for urgent (security or serious bug) updates.
if (status.urgentUpdate && !status.updateRequest) {
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). Request the update in {link}</span>
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). Please request the update in {link}</span>
</div>
);
}
if (status.updateAvailable && !status.updateRequest && !status.updateSnoozed) {
const notNow = async () => {
setBusy(true);
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
setBusy(false);
};
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). See what&apos;s new and request it in {link}</span>
</span>
<button onClick={notNow} disabled={busy} className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-brand-700 hover:bg-brand-100 disabled:opacity-50" title="Hide this until the next version comes out">
<BellOff className="w-3.5 h-3.5" /> Not now
</button>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubNotice } from "@/components/admin/LicenceTab";
const DISMISSED_KEY = "cross_events_dismissed_notices";
const LEVEL = {
info: { icon: Info, tone: "bg-sky-50 border-sky-200 text-sky-900" },
maintenance: { icon: Wrench, tone: "bg-amber-50 border-amber-200 text-amber-900" },
warning: { icon: AlertTriangle, tone: "bg-red-50 border-red-200 text-red-800" },
} as const;
// A notice the provider edits shows again, so it's remembered by id and last change.
const noticeKey = (n: HubNotice) => `${n.id}:${n.updatedAt || ""}`;
function readDismissed(): string[] {
try { return JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]"); } catch { return []; }
}
/**
* Notices from the provider (sent by the Cross Events Hub): maintenance, news and so on. Shown to
* site admins and supervisors only. Each can be closed on this browser; it's still listed on Site
* settings → Licence.
*/
export function ProviderNotices() {
const { user, token } = useAuth();
const [notices, setNotices] = useState<HubNotice[]>([]);
const [dismissed, setDismissed] = useState<string[]>([]);
const canSee = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (!canSee || !token) return;
setDismissed(readDismissed());
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
}, [canSee, token]);
if (!canSee) return null;
const shown = notices.filter(n => !dismissed.includes(noticeKey(n)));
if (!shown.length) return null;
const dismiss = (n: HubNotice) => {
// Keep only notices that still exist, so the list doesn't grow forever.
const next = [...dismissed.filter(k => notices.some(x => noticeKey(x) === k)), noticeKey(n)];
setDismissed(next);
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify(next)); } catch { /* private window: closed until reload */ }
};
return (
<>
{shown.map(n => {
const { icon: Icon, tone } = LEVEL[n.level] || LEVEL.info;
return (
<div key={n.id} role={n.level === "warning" ? "alert" : "status"} className={`border-b text-sm px-4 py-2 flex items-start gap-2 ${tone}`}>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="font-medium">{n.title}</span>
<span className="whitespace-pre-line"> — {n.message}</span>
</div>
<button onClick={() => dismiss(n)} className="shrink-0 rounded p-0.5 hover:bg-black/5" aria-label={`Close "${n.title}"`}>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</>
);
}
@@ -0,0 +1,33 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { QrImage } from "@/components/shared/QrImage";
import { Printer } from "lucide-react";
import { checkInUrl } from "@/lib/volunteers";
/** The event's volunteer check-in QR code, to show on a screen or print and put up at the venue. */
export function CheckInQr({ token, eventId, title }: { token: string | null; eventId: string; title: string }) {
const [qr, setQr] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token || !eventId) return;
setQr(null);
apiFetch<{ qr: string }>(`/api/volunteers/manage/events/${eventId}/qr`, { authToken: token })
.then(r => setQr(r.qr))
.catch(e => setError(errorMessage(e) || "Could not load the QR code"));
}, [token, eventId]);
if (error) return <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>;
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-center space-y-3">
<h2 className="text-xl font-semibold">Volunteers: check in here</h2>
<p className="text-sm text-gray-600">{title}</p>
{qr ? <QrImage value={checkInUrl(qr)} size={320} alt="Volunteer check-in QR code" className="mx-auto w-64 h-64" /> : <div className="mx-auto w-64 h-64 bg-gray-100 animate-pulse" />}
<p className="text-sm text-gray-700">Scan with your phone&apos;s camera and log in — you&apos;re checked in for your shifts today.</p>
<button onClick={() => window.print()} className="print:hidden px-3 py-1.5 rounded-lg border text-sm inline-flex items-center gap-1"><Printer className="w-4 h-4" /> Print</button>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { CheckCircle2, Pencil, Plus, Printer, Trash2, UserPlus, X } from "lucide-react";
import { fromLocalInput, toLocalInput, type Roster, type ServingRole } from "@/lib/volunteers";
type Msg = { type: "ok" | "err"; text: string };
type Props = {
token: string | null;
eventId: string;
/** Supervisors: add, change and delete roles, put people on and take them off. */
canEdit: boolean;
kidsRooms?: { id: string; name: string }[];
onMessage: (m: Msg) => void;
};
type Draft = { name: string; needed: string; sessionId: string; startsAt: string; endsAt: string; description: string; kidsRoomId: string };
const EMPTY: Draft = { name: "", needed: "1", sessionId: "", startsAt: "", endsAt: "", description: "", kidsRoomId: "" };
const inputCls = "w-full border rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const time = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const toDraft = (r: ServingRole): Draft => ({
name: r.name, needed: String(r.needed), sessionId: r.sessionId || "", startsAt: toLocalInput(r.startsAt), endsAt: toLocalInput(r.endsAt),
description: r.description, kidsRoomId: r.kidsRoom?.id || "",
});
/** The roster for one event: roles with their volunteers and check-ins (and, for supervisors, editing). */
export function RosterSheet({ token, eventId, canEdit, kidsRooms = [], onMessage }: Props) {
const [roster, setRoster] = useState<Roster | null>(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY);
const [assignFor, setAssignFor] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [found, setFound] = useState<{ id: string; name: string; email: string }[]>([]);
const load = useCallback(async (log = false) => {
if (!token || !eventId) return;
try {
setRoster(await apiFetch<Roster>(`/api/volunteers/manage/events/${eventId}${log ? "?log=1" : ""}`, { authToken: token }));
} catch (e) {
onMessage({ type: "err", text: errorMessage(e) || "Could not load the roster" });
}
}, [token, eventId, onMessage]);
// Opening the roster writes the access log (it shows contact details); refreshes don't.
useEffect(() => { setRoster(null); load(true); }, [load]);
useVisiblePolling(() => { load(); }, 30000, !!token && !!eventId);
const run = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
onMessage({ type: "ok", text: ok });
await load();
return true;
} catch (e) {
onMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
return false;
} finally {
setBusy(false);
}
};
const body = (d: Draft) => ({
name: d.name, needed: Number(d.needed) || 1, description: d.description,
...(roster?.event.scheduleType === "sessions" ? { sessionId: d.sessionId } : {}),
startsAt: fromLocalInput(d.startsAt), endsAt: fromLocalInput(d.endsAt), kidsRoomId: d.kidsRoomId || null,
});
const saveRole = () => run(
() => editing === "new"
? apiFetch(`/api/volunteers/manage/events/${eventId}/roles`, { method: "POST", authToken: token, body: body(draft) })
: apiFetch(`/api/volunteers/manage/roles/${editing}`, { method: "PUT", authToken: token, body: body(draft) }),
editing === "new" ? "Role added." : "Role saved.",
).then(ok => { if (ok) { setEditing(null); setDraft(EMPTY); } });
const deleteRole = (r: ServingRole) => confirm(`Delete ${r.name}?${r.filled ? ` The ${r.filled} volunteer${r.filled === 1 ? "" : "s"} on it will be told.` : ""}`)
&& run(() => apiFetch(`/api/volunteers/manage/roles/${r.id}`, { method: "DELETE", authToken: token }), "Role deleted.");
const search = async () => {
if (!token || query.trim().length < 2) return;
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(query.trim())}&limit=8&isActive=true`, { authToken: token });
setFound(res.data);
} catch (e) {
onMessage({ type: "err", text: errorMessage(e) || "Search failed" });
}
};
const assign = (roleId: string, u: { id: string; name: string }) => run(
() => apiFetch(`/api/volunteers/manage/roles/${roleId}/assign`, { method: "POST", authToken: token, body: { userId: u.id } }),
`${u.name} is on the roster.`,
).then(ok => { if (ok) { setAssignFor(null); setQuery(""); setFound([]); } });
const roleForm = (
<div className="border rounded-xl p-4 bg-gray-50 space-y-2" data-testid="role-form">
<div className="grid sm:grid-cols-[2fr_1fr] gap-2">
<label className="text-xs text-gray-500">Role<input aria-label="Role name" className={inputCls} value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="Welcome, Parking, Sound…" /></label>
<label className="text-xs text-gray-500">People needed<input aria-label="People needed" className={inputCls} inputMode="numeric" value={draft.needed} onChange={e => setDraft({ ...draft, needed: e.target.value })} /></label>
</div>
{roster?.event.scheduleType === "sessions" && (
<label className="text-xs text-gray-500 block">Session
<select aria-label="Session" className={inputCls} value={draft.sessionId} onChange={e => setDraft({ ...draft, sessionId: e.target.value })}>
<option value="">Choose…</option>
{roster.sessions.map(s => <option key={s.id} value={s.id}>{s.label} · {new Date(s.startsAt).toLocaleDateString()}</option>)}
</select>
</label>
)}
<div className="grid sm:grid-cols-2 gap-2">
<label className="text-xs text-gray-500">Shift starts (optional)<input aria-label="Shift starts" type="datetime-local" className={inputCls} value={draft.startsAt} onChange={e => setDraft({ ...draft, startsAt: e.target.value })} /></label>
<label className="text-xs text-gray-500">Shift ends (optional)<input aria-label="Shift ends" type="datetime-local" className={inputCls} value={draft.endsAt} onChange={e => setDraft({ ...draft, endsAt: e.target.value })} /></label>
</div>
<label className="text-xs text-gray-500 block">What it involves (optional)<input aria-label="Description" className={inputCls} value={draft.description} onChange={e => setDraft({ ...draft, description: e.target.value })} /></label>
{kidsRooms.length > 0 && (
<label className="text-xs text-gray-500 block">Kids room (volunteers count as leaders on duty there once they check in)
<select aria-label="Kids room" className={inputCls} value={draft.kidsRoomId} onChange={e => setDraft({ ...draft, kidsRoomId: e.target.value })}>
<option value="">Not a kids room</option>
{kidsRooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
)}
<p className="text-xs text-gray-500">Without shift times the role covers the {roster?.event.scheduleType === "sessions" ? "session" : "event"}&apos;s times. Nobody can be in two roles at the same time.</p>
<div className="flex gap-2">
<button disabled={busy || !draft.name.trim()} onClick={saveRole} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm disabled:opacity-50">{editing === "new" ? "Add role" : "Save role"}</button>
<button disabled={busy} onClick={() => { setEditing(null); setDraft(EMPTY); }} className="px-3 py-1.5 rounded-lg border text-sm">Cancel</button>
</div>
</div>
);
if (!roster) return <div className="text-sm text-gray-500">Loading the roster…</div>;
const groups = roster.event.scheduleType === "sessions"
? roster.sessions.map(s => ({ key: s.id, label: `${s.label} · ${new Date(s.startsAt).toLocaleDateString()}`, roles: roster.roles.filter(r => r.sessionId === s.id) })).filter(g => g.roles.length || canEdit)
: [{ key: "all", label: "", roles: roster.roles }];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-700" data-testid="roster-totals">
<strong>{roster.totals.filled}</strong> of {roster.totals.needed} places filled{roster.totals.fillRate != null ? ` (${roster.totals.fillRate}%)` : ""} · <strong>{roster.totals.checkedIn}</strong> checked in
</div>
<div className="flex gap-2 print:hidden">
{canEdit && editing === null && <button onClick={() => { setDraft(EMPTY); setEditing("new"); }} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm inline-flex items-center gap-1"><Plus className="w-4 h-4" /> Add a role</button>}
<button onClick={() => window.print()} className="px-3 py-1.5 rounded-lg border text-sm inline-flex items-center gap-1"><Printer className="w-4 h-4" /> Print</button>
</div>
</div>
{editing === "new" && roleForm}
{roster.roles.length === 0 && editing === null && <p className="text-sm text-gray-500">No serving roles yet.{canEdit ? " Add the first one." : ""}</p>}
{groups.map(g => (
<div key={g.key} className="space-y-3">
{g.label && <h3 className="text-sm font-semibold text-gray-700">{g.label}</h3>}
{g.roles.map(r => editing === r.id ? <React.Fragment key={r.id}>{roleForm}</React.Fragment> : (
<section key={r.id} className="border rounded-xl p-4 bg-white shadow-sm break-inside-avoid" data-testid="roster-role">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h4 className="font-semibold">{r.name} <span className={`ml-1 text-xs px-2 py-0.5 rounded-full ${r.filled >= r.needed ? "bg-green-100 text-green-800" : "bg-amber-100 text-amber-800"}`}>{r.filled}/{r.needed}</span></h4>
<div className="text-sm text-gray-600">{r.shift.text} · {r.shift.hours} h{r.kidsRoom ? ` · kids room ${r.kidsRoom.name}` : ""}</div>
{r.description && <div className="text-xs text-gray-500">{r.description}</div>}
</div>
{canEdit && (
<div className="flex gap-1 print:hidden">
<button aria-label={`Put someone on ${r.name}`} onClick={() => { setAssignFor(assignFor === r.id ? null : r.id); setFound([]); setQuery(""); }} className="p-1.5 rounded-lg border"><UserPlus className="w-4 h-4" /></button>
<button aria-label={`Edit ${r.name}`} onClick={() => { setDraft(toDraft(r)); setEditing(r.id); }} className="p-1.5 rounded-lg border"><Pencil className="w-4 h-4" /></button>
<button aria-label={`Delete ${r.name}`} disabled={busy} onClick={() => deleteRole(r)} className="p-1.5 rounded-lg border"><Trash2 className="w-4 h-4" /></button>
</div>
)}
</div>
{assignFor === r.id && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50 space-y-2 print:hidden">
<div className="flex gap-2">
<input aria-label="Find a person" className={inputCls} value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === "Enter" && search()} placeholder="Name, email or phone" />
<button onClick={search} className="px-3 py-1.5 rounded-lg border text-sm">Find</button>
</div>
{found.map(u => (
<button key={u.id} disabled={busy} onClick={() => assign(r.id, u)} className="block w-full text-left text-sm px-2 py-1 rounded hover:bg-white">
{u.name} <span className="text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
{(r.volunteers || []).length > 0 ? (
<ul className="mt-3 divide-y text-sm">
{r.volunteers!.map(vl => (
<li key={vl.slotId} className="py-1.5 flex flex-wrap items-center justify-between gap-2" data-testid="roster-volunteer">
<div>
<span className="font-medium">{vl.name}</span>
<span className="text-gray-500">{vl.phone ? ` · ${vl.phone}` : ""}{vl.email && !vl.phone ? ` · ${vl.email}` : ""}</span>
{vl.swapOpen && <span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">looking for cover</span>}
{vl.assignedBy && <span className="ml-2 text-xs text-gray-400">added by {vl.assignedBy}</span>}
</div>
<div className="flex items-center gap-2">
{vl.checkedInAt
? <span className="inline-flex items-center gap-1 text-green-700 text-xs"><CheckCircle2 className="w-4 h-4" /> {time(vl.checkedInAt)} {vl.checkInMethod === "qr" ? "(QR)" : `(${vl.checkedInBy || "staff"})`}</span>
: <span className="text-xs text-gray-400 print:inline hidden">☐</span>}
<span className="print:hidden flex gap-1">
{vl.checkedInAt
? <button disabled={busy} onClick={() => run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}/check-in`, { method: "POST", authToken: token, body: { undo: true } }), "Check-in undone.")} className="text-xs px-2 py-1 rounded-lg border">Undo</button>
: <button disabled={busy} onClick={() => run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}/check-in`, { method: "POST", authToken: token }), `${vl.name} checked in.`)} className="text-xs px-2 py-1 rounded-lg border">Check in</button>}
{canEdit && !vl.checkedInAt && <button aria-label={`Take ${vl.name} off ${r.name}`} disabled={busy} onClick={() => confirm(`Take ${vl.name} off ${r.name}? They'll be told.`) && run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}`, { method: "DELETE", authToken: token }), `${vl.name} is off the roster.`)} className="text-xs p-1 rounded-lg border"><X className="w-3.5 h-3.5" /></button>}
</span>
</div>
</li>
))}
</ul>
) : <p className="mt-2 text-sm text-gray-500">Nobody yet.</p>}
</section>
))}
</div>
))}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import React from "react";
import { Baby, BellRing, CloudOff, KeyRound, LogIn, LogOut, PenLine, QrCode, Settings, ShieldAlert, ShieldCheck, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/staff/kids (the kids check-in desk). */
export const kidsDeskHelpContent: HelpContent = {
title: "Kids check-in desk",
subtitle: "Check children in and out with pickup codes — no printer needed.",
tabs: [
{
key: "in",
label: "Checking in",
icon: LogIn,
content: (
<div className="space-y-4">
<p>Pick the event (and session) at the top. Find the family by a parent&apos;s or child&apos;s name or phone number, or tap &quot;Scan family QR&quot; and scan the code on the parent&apos;s phone (My account → Kids). New here? Tap &quot;New family&quot; and add the parent and their first child.</p>
<div className="space-y-4">
<GuideItem icon={Users} title="Ticking children and rooms" tone="brand">
Tick the children being dropped off. Each one gets a room suggested from their age — the room with the narrowest age range that isn&apos;t full. Change it if you need to. Children already in show &quot;In — room&quot;.
</GuideItem>
<GuideItem icon={KeyRound} title="The pickup code" tone="green">
Checking in gives the family a 4-character pickup code for today (or this session), shared by all the children checked in together. It&apos;s sent to the parents by WhatsApp or email and shows on their phone. You can also send it to one of the family&apos;s authorised adults. Anyone with the code can collect the children.
</GuideItem>
<GuideItem icon={PenLine} title="Write on the sticker" tone="amber">
Nothing is printed. The screen shows, in large letters, what to copy onto a blank name sticker for each child: first name, room, the pickup code and a short alert like ALLERGY: PEANUTS or MEDICAL. The full details stay on the room screen.
</GuideItem>
</div>
</div>
),
},
{
key: "out",
label: "Checking out",
icon: LogOut,
content: (
<div className="space-y-4">
<p>On &quot;Check out&quot;, type the code or scan the QR on the parent&apos;s phone. You&apos;ll see the family, the children on that code (with any custody notes) and who may collect them. Untick any child staying behind, choose or type who is collecting, and tap Check out.</p>
<GuideItem icon={ShieldAlert} title="A wrong code" tone="rose">
A code that doesn&apos;t match anyone is refused, recorded in the audit log and shown as a red alert on the room screens — check the person carefully before handing over any child.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Without the code" tone="gray">
Phone dead or code lost? On &quot;Who&apos;s in&quot; (or the room screen), tap &quot;Without code&quot; on the child. You must say who is collecting and why; it&apos;s recorded in the audit log.
</GuideItem>
</div>
),
},
{
key: "offline",
label: "Offline",
icon: CloudOff,
content: (
<div className="space-y-4">
<p>Before the service, while the Wi-Fi works, tap &quot;Get ready for offline&quot;. The desk downloads the families and 20 pickup codes kept for this device. If the connection drops, the desk switches to the offline list (or tap &quot;Work offline&quot;).</p>
<ul className="list-disc pl-5 space-y-1">
<li>Offline, no messages are sent — tell the parent their code.</li>
<li>Offline, you can only check out children checked in on <strong>this</strong> device.</li>
<li>New families and family QR codes need a connection; search by name or phone instead.</li>
<li>Back online, tap &quot;Sync&quot;. If a family got a different code from another desk meanwhile, you&apos;ll be told which code to give them.</li>
</ul>
</div>
),
},
],
quickLinks: [{ label: "Room screen", description: "Children in each room, allergies and alerts", href: "/dashboard/staff/kids/rooms", icon: Users }],
};
/** Help content for /dashboard/staff/kids/rooms. */
export const kidsRoomsHelpContent: HelpContent = {
title: "Kids rooms",
subtitle: "Who is in your room, what to watch for, and calling a parent.",
tabs: [
{
key: "room",
label: "Your room",
icon: Users,
content: (
<div className="space-y-4">
<p>Choose the event and your room (the screen remembers it). Each child&apos;s card shows their pickup code, allergies (red), medical notes (amber) and custody notes — for example &quot;Do not release to …&quot; — with the parents&apos; names and the adults allowed to collect. The screen refreshes every few seconds.</p>
<GuideItem icon={Baby} title="I'm on duty" tone="brand">
Tap &quot;I&apos;m on duty in this room&quot; when you start, and &quot;I&apos;m going off duty&quot; when you leave. With a ratio set (e.g. 5 children per leader), the room shows &quot;Over ratio&quot; when there are more children than the leaders on duty can look after.
</GuideItem>
<GuideItem icon={BellRing} title="Call parent" tone="amber">
Sends the parents a WhatsApp (or email) asking them to come to the room, and shows it on their Kids page. Medical details are never put in the message.
</GuideItem>
<GuideItem icon={ShieldAlert} title="Alerts" tone="rose">
When someone at the desk enters a wrong pickup code, a red alert appears at the top. Tap &quot;Dealt with&quot; once you&apos;ve checked.
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/supervisor/kids. */
export const kidsSetupHelpContent: HelpContent = {
title: "Kids check-in setup",
subtitle: "Switch kids check-in on for an event and set up its rooms.",
tabs: [
{
key: "setup",
label: "Setting up",
icon: Settings,
content: (
<div className="space-y-4">
<p>For weekly kids church, use one event with a session per Sunday — families and children are kept between sessions, so parents only add their children once.</p>
<ol className="list-decimal pl-5 space-y-1">
<li><strong>Kids ticket:</strong> a main ticket covering the whole event. Each child gets a free place on it the first time they&apos;re checked in, so they show on the attendance register and reports. &quot;Add a free Kids check-in ticket&quot; makes one.</li>
<li><strong>Rooms:</strong> an allocation set; each group is a room with an age range, a capacity and how many children one leader may look after.</li>
<li><strong>Switch on.</strong> Staff then use Staff → Kids check-in.</li>
</ol>
<GuideItem icon={ShieldCheck} title="Children's information" tone="rose">
Allergies, medical and custody notes are stored encrypted, shown only to staff at the desk and in the rooms (opening those screens is written to the access log) and never sent in messages. Only parents, supervisors and admins can change custody notes. A child&apos;s profile is deleted after 6 months without a check-in (Admin → Privacy → Retention).
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/user/kids. */
export const userKidsHelpContent: HelpContent = {
title: "Kids",
subtitle: "Your children for kids check-in, and their pickup codes.",
tabs: [
{
key: "family",
label: "Your family",
icon: Baby,
content: (
<div className="space-y-4">
<p>Add your children once — with their date of birth (so the right room is suggested), allergies and anything leaders should know. Custody notes (e.g. someone who may not collect them) are only shown to the room leaders.</p>
<GuideItem icon={Users} title="Authorised adults" tone="brand">
List the people who may collect your children, such as grandparents. They don&apos;t need an account. The room leader sees the list, and the desk can send them the pickup code.
</GuideItem>
<GuideItem icon={QrCode} title="Family QR code" tone="gray">
Show it at the kids desk to find your family quickly. It can&apos;t be used to collect children.
</GuideItem>
</div>
),
},
{
key: "codes",
label: "Pickup codes",
icon: KeyRound,
content: (
<div className="space-y-4">
<p>When your children are checked in, their pickup code shows at the top of this page with a QR code, and is sent to you. Show it to collect them. Anyone you give the code to can collect them — use &quot;Send the code to someone else&quot;. If a leader asks you to come to the room, it shows here too.</p>
<p className="text-xs text-gray-500">A child&apos;s details are deleted after 6 months without a check-in. You can remove them at any time.</p>
</div>
),
},
],
};
+3 -3
View File
@@ -33,7 +33,7 @@ export const userPrivacyHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<GuideItem icon={Download} title="Download my data" tone="brand">
The PDF is easy to read; the JSON file is the complete copy. Both include your registrations, the people you registered, form answers, payments, tickets and check-ins.
The PDF is easy to read; the JSON file is the complete copy. Both include your registrations, the people you registered, form answers, payments, tickets and check-ins, and your family and children for kids check-in.
</GuideItem>
<GuideItem icon={PencilLine} title="Correct my data" tone="green">
Change your name, email and phone in Profile &amp; Security. For anything else, send a correction request — we answer by email within 30 days.
@@ -62,7 +62,7 @@ export const adminPrivacyHelpContent: HelpContent = {
A checklist with a score: Information Officer set, notice published, operators described, retention reviewed and running, requests answered on time, breaches reported, unsubscribe and WhatsApp STOP working, backups, two-factor login. Each open item links to where you fix it.
</GuideItem>
<GuideItem icon={Timer} title="Retention" tone="amber">
How long each kind of personal information is kept. Every night at 03:30 the retention job deletes or anonymises what&apos;s older: inactive accounts (after a warning 30 days before — logging in keeps the account), event form answers, login history, payer details from payment notifications, and old audit/access records. The privacy notice&apos;s retention section follows these settings ({"{retention}"}). Review and save them, even if you keep the defaults.
How long each kind of personal information is kept. Every night at 03:30 the retention job deletes or anonymises what&apos;s older: inactive accounts (after a warning 30 days before — logging in keeps the account), event form answers, children at kids check-in (after 6 months without a check-in), login history, payer details from payment notifications, and old audit/access records. The privacy notice&apos;s retention section follows these settings ({"{retention}"}). Review and save them, even if you keep the defaults.
</GuideItem>
<GuideItem icon={AlertTriangle} title="Breaches" tone="rose">
Record any security compromise. POPIA requires telling the Information Regulator and the people affected as soon as reasonably possible; each entry has a draft for both, and you record when you told them. Readiness flags any not reported to the Regulator within 3 days.
@@ -101,7 +101,7 @@ export const adminPrivacyHelpContent: HelpContent = {
Proof of consent: every news opt-in and opt-out (and how — sign-up, unsubscribe link, STOP reply…), every privacy notice acceptance, and every parental consent answer.
</GuideItem>
<GuideItem icon={Eye} title="Access log" tone="violet">
Who downloaded, emailed or opened personal information in bulk — reports, attendance registers, certificates, group lists, form answers, consent and medical lists, backups — and every &quot;view as&quot; session.
Who downloaded, emailed or opened personal information in bulk — reports, attendance registers, certificates, group lists, form answers, consent and medical lists, the kids check-in desk and room screens, backups — and every &quot;view as&quot; session.
</GuideItem>
</div>
),
+37
View File
@@ -34,6 +34,8 @@ import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-at
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
import { staffTicketScanningHelpContent } from "./staff-ticket-scanning";
import { userFormsHelpContent } from "./user-forms";
import { kidsDeskHelpContent, kidsRoomsHelpContent, kidsSetupHelpContent, userKidsHelpContent } from "./kids";
import { staffVolunteersHelpContent, userServingHelpContent, volunteerRostersHelpContent } from "./volunteers";
type HelpRegistryEntry = {
/** Unique, stable identifier for this guide. */
@@ -160,6 +162,41 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/allocations",
content: supervisorAllocationsHelpContent,
},
{
slug: "staff-kids-rooms",
matches: pathname => pathname === "/dashboard/staff/kids/rooms",
content: kidsRoomsHelpContent,
},
{
slug: "staff-kids",
matches: pathname => pathname === "/dashboard/staff/kids",
content: kidsDeskHelpContent,
},
{
slug: "supervisor-kids",
matches: pathname => pathname === "/dashboard/supervisor/kids",
content: kidsSetupHelpContent,
},
{
slug: "user-kids",
matches: pathname => pathname === "/dashboard/user/kids",
content: userKidsHelpContent,
},
{
slug: "supervisor-volunteers",
matches: pathname => pathname === "/dashboard/supervisor/volunteers",
content: volunteerRostersHelpContent,
},
{
slug: "staff-volunteers",
matches: pathname => pathname === "/dashboard/staff/volunteers",
content: staffVolunteersHelpContent,
},
{
slug: "user-serving",
matches: pathname => pathname === "/dashboard/user/serving",
content: userServingHelpContent,
},
{
slug: "staff-my-groups",
matches: pathname => pathname === "/dashboard/staff/my-groups",
+8 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -184,7 +184,13 @@ export const siteSettingsHelpContent: HelpContent = {
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
</GuideItem>
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
When a new version is out, press Request update. Your provider is told and updates the site for you — there&apos;s nothing to run yourself.
When a new version is out, the Version box shows what&apos;s new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there&apos;s nothing to run yourself. Changed your mind? Withdraw request tells your provider not to.
</GuideItem>
<GuideItem icon={BellOff} title="Not now" tone="gray">
Not now (on the Licence page or the update banner) hides the reminder until the next version comes out. Updates that fix a security problem or a serious bug can&apos;t be put off.
</GuideItem>
<GuideItem icon={Megaphone} title="Notices from your provider" tone="brand">
Your provider can post notices, such as planned maintenance. Site admins and supervisors see them as a banner at the top of the dashboard; close one with ×. Current notices are also listed on this page.
</GuideItem>
</div>
</div>
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck } from "lucide-react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck, Copy } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -16,6 +16,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<div className="space-y-4">
<p>Search or toggle Include past / Show inactive (admin only) / Show closed to find an event, then tap &quot;+ Add Event&quot; or click a card to edit.</p>
<div className="space-y-4">
<GuideItem icon={Copy} title="Copy an event" tone="brand">
&quot;Copy&quot; on an event card opens the editor filled in from that event: details, campus, sessions, ticket options, variants and early birds, sections, the form, notifications, feedback questions, certificates and reminders. Set the <strong>New start</strong> at the top and every date moves with it — the end, the sessions, the registration deadline, early-bird deadlines and the feedback date. Check each step, change what&apos;s different this time, and save. Groups (rooms, buses, teams) are copied too, and for admins the planned costs. Registrations, coupons, attachments, kids check-in and volunteer roles are not.
</GuideItem>
<GuideItem icon={Lock} title="Closed events" tone="rose">
An event that&apos;s been cashed up shows a banner and can&apos;t be edited — it must be reopened from Cashup first.
</GuideItem>
@@ -65,7 +68,7 @@ export const supervisorEventsHelpContent: HelpContent = {
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. &quot;Preview certificate&quot; shows a sample.
</GuideItem>
</div>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event, or a copy, lets you jump to any step freely.</p>
</div>
),
},
+113
View File
@@ -0,0 +1,113 @@
import React from "react";
import { Baby, BarChart2, CheckCircle2, ClipboardList, HandHeart, QrCode, Repeat, Ticket, UserPlus } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/volunteers (setting up rosters). */
export const volunteerRostersHelpContent: HelpContent = {
title: "Volunteer rosters",
subtitle: "Plan who serves where, for an event or each session.",
tabs: [
{
key: "roles",
label: "Roles",
icon: ClipboardList,
content: (
<div className="space-y-4">
<p>Pick the event and tap &quot;Add a role&quot;: Welcome, Parking, Kids, Sound, Kitchen… with the number of people needed. On an event with sessions, each role is for one session — add the roles to every session that needs them. Rosters never repeat by themselves.</p>
<GuideItem icon={HandHeart} title="Shift times" tone="brand">
Leave the times empty and the role covers the session&apos;s (or event&apos;s) times. Give times for shifts like setting up before the service. Nobody can be in two roles at the same time, so give times when one person does two jobs one after the other.
</GuideItem>
<GuideItem icon={Baby} title="Kids rooms" tone="amber">
When kids check-in is set up for the event, link a role to a kids room. Its volunteers count as leaders on duty in that room once they check in, for the room&apos;s ratio. The room screen also lists who is rostered but not in yet.
</GuideItem>
</div>
),
},
{
key: "people",
label: "People",
icon: UserPlus,
content: (
<div className="space-y-4">
<p>Anyone with an account can sign up for a role that isn&apos;t full, under My account → Serving. You can also put someone on a role with the person button — even when it&apos;s full. They&apos;re told either way, and get a reminder the day before their shift.</p>
<GuideItem icon={Repeat} title="Swaps" tone="gray">
A volunteer who can&apos;t make it asks for cover. The event&apos;s other volunteers are told, and the first to accept takes the place straight away — nobody needs to approve it. The event&apos;s notification recipients get an email about every swap, and when someone withdraws.
</GuideItem>
<p>Delete a role or take someone off with the bin or ✕ — the volunteers are told. Once someone has checked in, they stay on (it counts for hours served).</p>
</div>
),
},
{
key: "day",
label: "On the day",
icon: QrCode,
content: (
<div className="space-y-4">
<p>Show or print the &quot;Check-in QR&quot; and put it up where volunteers arrive. They scan it with their phone&apos;s camera, log in, and are checked in for their shifts that day. Staff can also tick people in on the roster (Staff → Volunteers), and undo it.</p>
<GuideItem icon={CheckCircle2} title="The roster sheet" tone="green">
Each role shows who is serving, their phone number, and when they checked in (QR or by whom). &quot;Print&quot; gives a paper sheet with tick boxes.
</GuideItem>
</div>
),
},
{
key: "ticket",
label: "Ticket and hours",
icon: Ticket,
content: (
<div className="space-y-4">
<GuideItem icon={Ticket} title="Free volunteer ticket" tone="brand">
Off by default. Choose a ticket of the event (or add a free &quot;Volunteer&quot; ticket) and everyone on the roster gets a free place on it, unless they already have a ticket. It&apos;s never offered to the public and is given back when they leave the roster.
</GuideItem>
<GuideItem icon={BarChart2} title="Hours served" tone="gray">
Hours per person over all events (or one), counting the shifts they checked in for at the shift&apos;s length.
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/staff/volunteers (the roster on the day). */
export const staffVolunteersHelpContent: HelpContent = {
title: "Volunteers",
subtitle: "Who is serving today, and checking them in.",
tabs: [
{
key: "day",
label: "On the day",
icon: CheckCircle2,
content: (
<div className="space-y-4">
<p>Pick the event. The roster shows every role, who is on it, and who has checked in. Volunteers check themselves in by scanning the check-in QR code — tap &quot;Show the check-in QR code&quot; to put it on a screen. Someone without a phone? Tap &quot;Check in&quot; next to their name.</p>
<p>Volunteers in a role linked to a kids room count as leaders on duty in that room once they&apos;re checked in. Undoing a check-in takes them off duty.</p>
</div>
),
},
],
};
/** Help content for /dashboard/user/serving. */
export const userServingHelpContent: HelpContent = {
title: "Serving",
subtitle: "Your volunteer shifts.",
tabs: [
{
key: "shifts",
label: "Your shifts",
icon: HandHeart,
content: (
<div className="space-y-4">
<p>Choose an event under &quot;Serve at an event&quot; and sign up for a role that has space. You&apos;ll get a reminder the day before. When you arrive, scan the volunteer check-in QR code with your phone&apos;s camera.</p>
<GuideItem icon={Repeat} title="Can't make it?" tone="amber">
Tap &quot;Can&apos;t make it&quot;. The other volunteers at the event are asked to take over, and you stay on the roster until someone does — then you&apos;re told. If you can make it after all, cancel the request. You can also withdraw; the organisers are told.
</GuideItem>
<GuideItem icon={Ticket} title="Volunteer ticket" tone="gray">
Some events give volunteers a free ticket. It shows in My Events.
</GuideItem>
</div>
),
},
],
};
+130
View File
@@ -0,0 +1,130 @@
// Kids check-in (1.45.0): shared types and the offline desk's local copy.
export type KidsChild = {
id: string; firstName: string; lastName: string; name: string; dateOfBirth: string | null; age: number | null;
gender: string | null; allergies: string; hasMedical: boolean; hasCustody: boolean;
medicalNotes?: string; custodyNotes?: string; lastCheckInAt: string | null;
};
export type KidsParent = { userId: string; name: string; phone: string | null; email: string | null };
export type KidsAdult = { id: string; name: string; phone: string | null; relationship: string | null };
export type KidsFamily = { id: string; name: string; parents: KidsParent[]; children: KidsChild[]; adults: KidsAdult[] };
export type KidsRoom = {
id: string; name: string; minAge: number | null; maxAge: number | null; capacity: number | null; ratio: number | null; order: number;
leaderNames: string[]; leaderIds: string[];
/** Volunteers rostered for the room (1.46.0); they count as leaders once checked in. */
rostered: { userId: string; name: string; checkedIn: boolean }[];
children: number; leaders: number; leadersNeeded: number; full: boolean; overRatio: boolean;
};
export type KidsCheckIn = {
id: string; childId: string | null; name: string; firstName: string; age: number | null; roomId: string | null; code: string;
checkedInAt: string; checkedInBy: string | null; checkedOutAt: string | null; checkedOutBy: string | null; collectedBy: string | null;
override: boolean; offline: boolean; allergies: string; medicalNotes: string; custodyNotes: string; sticker: string;
family: { id: string; name: string; parents: KidsParent[]; adults: KidsAdult[] } | null;
};
export type KidsAlert = { id: string; kind: "wrong_code" | "page_parent" | "override"; roomId: string | null; checkInId: string | null; message: string; by: string | null; at: string; resolvedAt: string | null };
export type KidsBoard = {
event: { id: string; title: string; scheduleType: string };
occasion: string; sessionId: string | null; sessionLabel: string | null;
rooms: KidsRoom[]; checkIns: KidsCheckIn[]; alerts: KidsAlert[];
};
export type KidsEvent = {
id: string; title: string; startDate: string; endDate: string; scheduleType: string; campusId: string | null;
sessions: { id: string; label: string | null; startsAt: string; endsAt: string }[];
};
export type CheckInResult = {
code: string; qr: string; occasion: string; family: { id: string; name: string };
checkIns: { id: string; name: string; firstName: string; room: { id: string; name: string } | null; already: boolean; sticker: string }[];
};
export const QR_PREFIX = "KIDS-";
export const FAMILY_QR_PREFIX = "FAMILY-";
/** "2–4 yrs", "4+ yrs", "up to 3 yrs", "" */
export function ageRange(r: { minAge: number | null; maxAge: number | null }) {
if (r.minAge != null && r.maxAge != null) return `${r.minAge}–${r.maxAge} yrs`;
if (r.minAge != null) return `${r.minAge}+ yrs`;
if (r.maxAge != null) return `up to ${r.maxAge} yrs`;
return "";
}
/** The same choice as the backend's suggestRoom: narrowest fitting room that isn't full. */
export function suggestRoom(rooms: KidsRoom[], age: number | null, extra: Record<string, number> = {}) {
const fits = rooms.filter(r =>
(age == null || ((r.minAge == null || age >= r.minAge) && (r.maxAge == null || age <= r.maxAge)))
&& !(r.capacity && r.children + (extra[r.id] || 0) >= r.capacity));
const span = (r: KidsRoom) => (r.maxAge ?? 25) - (r.minAge ?? 0);
return [...fits].sort((a, b) => (age == null ? 0 : span(a) - span(b)) || a.order - b.order)[0] || rooms[0] || null;
}
export function stickerAlert(allergies: string, medical: boolean) {
const parts: string[] = [];
const a = (allergies || "").trim();
if (a) parts.push(`ALLERGY: ${a.toUpperCase().slice(0, 24)}${a.length > 24 ? "…" : ""}`);
if (medical) parts.push("MEDICAL");
return parts.join(" · ");
}
// ─── Offline desk ─────────────────────────────────────────────────────────────
export type OfflineSnapshot = {
occasion: string; sessionId: string | null; sessionLabel: string | null;
event: { id: string; title: string; scheduleType: string };
rooms: KidsRoom[]; checkIns: KidsCheckIn[]; families: KidsFamily[]; codes: string[]; downloadedAt: string;
};
export type OfflineCheckIn = { clientId: string; familyId: string; childId: string; roomId: string; code: string; at: string; name: string; roomName: string };
export type OfflineCheckOut = { clientId: string; collectedBy: string; at: string };
export type OfflineState = {
snapshot: OfflineSnapshot;
// Codes handed out offline, per family (one code per family per occasion).
familyCodes: Record<string, string>;
checkIns: OfflineCheckIn[];
checkOuts: OfflineCheckOut[];
};
const key = (eventId: string) => `hope_events_kids_offline_${eventId}`;
export function deviceId(): string {
const k = "hope_events_kids_device";
try {
let id = localStorage.getItem(k);
if (!id) {
id = `kd-${crypto.randomUUID()}`;
localStorage.setItem(k, id);
}
return id;
} catch {
return "kd-unknown";
}
}
export function loadOffline(eventId: string): OfflineState | null {
try {
const raw = localStorage.getItem(key(eventId));
return raw ? (JSON.parse(raw) as OfflineState) : null;
} catch {
return null;
}
}
export function saveOffline(eventId: string, state: OfflineState | null) {
try {
if (state) localStorage.setItem(key(eventId), JSON.stringify(state));
else localStorage.removeItem(key(eventId));
} catch {
// Storage full or blocked: the desk keeps working online.
}
}
/** Check children in on the offline copy. Returns the code, or throws when the device has no codes left. */
export function offlineCheckIn(state: OfflineState, family: KidsFamily, picks: { child: KidsChild; room: KidsRoom }[]): { state: OfflineState; code: string } {
const used = new Set(Object.values(state.familyCodes));
const code = state.familyCodes[family.id] || state.snapshot.codes.find(c => !used.has(c));
if (!code) throw new Error("This device has no offline pickup codes left. Go back online to get more.");
const at = new Date().toISOString();
const already = new Set(state.checkIns.filter(c => !state.checkOuts.some(o => o.clientId === c.clientId)).map(c => c.childId));
const fresh = picks.filter(p => !already.has(p.child.id)).map(p => ({
clientId: `kc-${crypto.randomUUID()}`, familyId: family.id, childId: p.child.id, roomId: p.room.id, code, at, name: p.child.name, roomName: p.room.name,
}));
return { code, state: { ...state, familyCodes: { ...state.familyCodes, [family.id]: code }, checkIns: [...state.checkIns, ...fresh] } };
}
+69
View File
@@ -0,0 +1,69 @@
/** Volunteer rosters (1.46.0): shared types and helpers for the Serving, roster and check-in pages. */
export type Shift = { startsAt: string; endsAt: string; text: string; hours?: number };
export type RosterVolunteer = {
slotId: string; userId: string; name: string; phone: string | null; email: string | null;
assignedBy: string | null; checkedInAt: string | null; checkedInBy: string | null; checkInMethod: "qr" | "manual" | null;
swapOpen: boolean; hasTicket: boolean;
};
export type ServingRole = {
id: string; eventId: string; sessionId: string | null; sessionLabel: string | null;
name: string; description: string; startsAt: string | null; endsAt: string | null;
shift: Shift & { hours: number }; kidsRoom: { id: string; name: string } | null; order: number; past: boolean;
needed: number; filled: number; open: number; full: boolean;
mine: { slotId: string; checkedInAt: string | null; swapId: string | null } | null;
coverNeeded: { swapId: string; note: string }[];
/** Staff only. */
volunteers?: RosterVolunteer[];
};
export type Roster = {
event: { id: string; title: string; startDate: string; endDate: string; scheduleType: string; location: string | null; kidsCheckIn: boolean };
sessions: { id: string; label: string; startsAt: string; endsAt: string }[];
roles: ServingRole[];
totals: { needed: number; filled: number; checkedIn: number; fillRate: number | null };
};
export type MySlot = {
slotId: string;
role: { id: string; name: string; description: string; sessionLabel: string | null; kidsRoom: string | null };
event: { id: string; title: string; location: string | null };
shift: Shift & { hours: number };
checkedInAt: string | null; canCheckIn: boolean; past: boolean; swapId: string | null; hasTicket: boolean;
};
export type CoverRequest = {
swapId: string; note: string; from: string | null;
role: { id: string; name: string; sessionLabel: string | null };
event: { id: string; title: string };
shift: Shift;
};
export type ServingEvent = { id: string; title: string; startDate: string; endDate: string; location: string | null; roles: number };
export type RosterEvent = { id: string; title: string; startDate: string; endDate: string; scheduleType: string; campusId: string | null; roles: number };
export type HoursRow = { userId: string; name: string; shifts: number; hours: number; events: number };
/** The page the volunteer check-in QR code opens (phone cameras open it straight away). */
export function checkInUrl(qr: string): string {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/serve/check-in?code=${encodeURIComponent(qr)}`;
}
/** A Date as the value of a datetime-local input (local time), or "". */
export function toLocalInput(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A datetime-local value as an ISO string, or null when empty. */
export function fromLocalInput(v: string): string | null {
return v ? new Date(v).toISOString() : null;
}
export const hoursText = (h: number) => `${h % 1 === 0 ? h : h.toFixed(2).replace(/0$/, "")} h`;
+4
View File
@@ -178,6 +178,10 @@ export type Event = {
sessionReminders?: { offsets: number[]; channels: ("email" | "whatsapp")[] } | null;
certificatesEnabled?: boolean;
certificateConfig?: CertificateConfig | null;
// Staff-only tickets and rooms (kids check-in 1.45.0, volunteer rosters 1.46.0).
kidsOptionId?: string | null;
kidsRoomSetId?: string | null;
volunteerOptionId?: string | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
isSoldOut?: boolean;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.44.0",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.44.0",
"version": "1.47.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.44.0",
"version": "1.47.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
-1
View File
@@ -6,7 +6,6 @@
# Usage (on the server, from the repo folder):
# bash scripts/deploy.sh # the latest main
# bash scripts/deploy.sh v1.37.3 # a tagged release
# CI runs it with the tag over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
#
# PM2 uses this install's own ecosystem.config.js (app names, ports). It isn't in git, so
# deploys never change it; the first deploy creates it from ecosystem.config.example.js.