Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35805f0c79 | ||
|
|
174022296b | ||
|
|
bc22a94f92 | ||
|
|
bbde6791cb | ||
|
|
e9a2ce9d01 | ||
|
|
39885a8719 | ||
|
|
1ce6f23ad8 | ||
|
|
1dbc37ba08 |
@@ -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'"
|
||||
@@ -7,6 +7,70 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+54
-14
@@ -31,12 +31,13 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Multi-campus](#multi-campus)
|
||||
17. [Kids check-in](#kids-check-in)
|
||||
18. [Accounts & security](#accounts--security)
|
||||
19. [Privacy (POPIA)](#privacy-popia)
|
||||
20. [Help system](#help-system)
|
||||
21. [Background jobs](#background-jobs)
|
||||
22. [Operations](#operations)
|
||||
23. [Page map](#page-map)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -397,6 +398,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
and print an event's tickets.
|
||||
- **Kids check-in** (`/dashboard/staff/kids`) and the **room screen**
|
||||
(`/dashboard/staff/kids/rooms`) — see [Kids check-in](#kids-check-in).
|
||||
- **Volunteers** (`/dashboard/staff/volunteers`) — today's roster, ticking volunteers in and the
|
||||
check-in QR code — see [Volunteer rosters](#volunteer-rosters).
|
||||
|
||||
---
|
||||
|
||||
@@ -594,6 +597,43 @@ label printers.
|
||||
changed only by parents, supervisors and admins (audited); opening the desk/room screens and
|
||||
offline downloads are in the access log; a child's profile is deleted after **6 months** without
|
||||
a check-in (Admin → Privacy → Retention).
|
||||
- **Volunteers in kids rooms:** a serving role linked to a room puts its volunteers on duty there
|
||||
when they check in; the room screen lists who is rostered but not in yet.
|
||||
- The kids ticket is never offered publicly (left out of public event pages, refused on public
|
||||
registration).
|
||||
|
||||
---
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Who serves where, for an event or each session.
|
||||
|
||||
- **Roles** (Supervisor → Volunteer rosters, `/dashboard/supervisor/volunteers`): name, people
|
||||
needed, optional shift times (default: the session's or event's times), description, and
|
||||
optionally a kids room. On a multi-session event each role is for one session. A role can't be
|
||||
deleted once someone has checked in for it.
|
||||
- **Volunteering** (My account → Serving, `/dashboard/user/serving`): anyone with an account signs
|
||||
up for a role that isn't full; supervisors can also put people on (beyond the number needed).
|
||||
Nobody can be in two roles whose shifts overlap. `?event=<id>` opens one event (the sign-up link
|
||||
supervisors share, also for hidden events). Volunteers are told when they're put on, taken off,
|
||||
or a role is deleted, and get a **reminder the day before**.
|
||||
- **Swaps:** "Can't make it" asks for cover — the event's other volunteers are told and the request
|
||||
shows on everyone's Serving page; the first to accept takes the place at once (no approval). The
|
||||
event's notification recipients (or its creator) are emailed about every swap and withdrawal.
|
||||
- **Check-in:** volunteers scan the event's **check-in QR code** (Check-in QR tab, or Staff →
|
||||
Volunteers) with their phone camera → `/serve/check-in` → every shift of theirs on that day is
|
||||
checked in. Staff can tick people in by hand (and undo). Check-in opens on the day of the shift
|
||||
and closes when it ends.
|
||||
- **Kids rooms:** checking in for a role linked to a kids room puts the volunteer on duty in that
|
||||
room (counted for the ratio, like "I'm on duty"); undoing the check-in takes them off.
|
||||
- **Volunteer ticket** (off by default): choose a main ticket of the event, or add a free
|
||||
"Volunteer" ticket. Volunteers without a ticket get a free place on it (in My Events); it's
|
||||
cancelled when they leave the roster (unless it was scanned), moves with a swap, and is never
|
||||
offered publicly.
|
||||
- **Reports:** roster sheet (phone numbers, check-in time and how), printable; fill rate per event;
|
||||
**hours served** per person (checked-in shifts × shift length) over all events or one.
|
||||
- **Read-only licence:** new roles and sign-ups are blocked; check-ins, swaps and withdrawals keep
|
||||
working.
|
||||
|
||||
---
|
||||
|
||||
@@ -713,6 +753,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`) |
|
||||
@@ -748,11 +789,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).
|
||||
|
||||
---
|
||||
@@ -777,9 +816,10 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
|
||||
| `/serve/check-in?code=…` | User (logs in first) | Volunteer check-in (where the event's check-in QR code leads) |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -92,9 +92,9 @@ Documentation: see Frontend README for environment and scripts.
|
||||
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
|
||||
servers and database aren't touched. The database user needs permission to create
|
||||
databases.
|
||||
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
|
||||
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
|
||||
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
|
||||
- There's no CI: run these yourself, plus `npx eslint .` in `frontend/`, before tagging a
|
||||
release (the deploy's `next build` fails on any lint error). Releases are deployed by hand
|
||||
with `scripts/deploy.sh` (see [DEPLOYMENT.md](./DEPLOYMENT.md)).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
|
||||
| `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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -922,6 +922,41 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
|
||||
| GET | `/api/kids/events/:eventId/offline?deviceId=&sessionId=` | staff+ | Offline snapshot: board plus `families` and `codes` (20 reserved for the device). Access log `kids_offline` |
|
||||
| POST | `/api/kids/events/:eventId/sync` | staff+ | `{ deviceId, occasion, checkIns: [{ clientId, familyId, childId, roomId, code, at }], checkOuts: [{ clientId, collectedBy, at }] }` → `{ checkedIn, checkedOut, conflicts }`; idempotent by `clientId`; audited as `kids_offline_sync` |
|
||||
|
||||
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
|
||||
role linked to the room); checked-in ones are counted in `leaders`.
|
||||
|
||||
## Volunteer rosters
|
||||
|
||||
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
|
||||
one session of a multi-session event (`sessionId` required there), with `needed` people and optional
|
||||
`startsAt`/`endsAt` (default: the session's or event's times). A **slot** is a person on a role;
|
||||
nobody can hold two slots whose shifts overlap (`409 SHIFT_CLASH`). When `Event.volunteerOptionId`
|
||||
is set, each volunteer without a ticket gets a free place on it (cancelled when they leave, moved
|
||||
with a swap). `volunteerOptionId` and `kidsOptionId` are left out of public event pages
|
||||
(`GET /api/events`, `GET /api/events/:id` for non-staff) and refused on public registration. Staff
|
||||
routes are campus-scoped.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/volunteers/me` | user | `{ slots, cover, events }` — my shifts (upcoming and the last week; `canCheckIn`, `swapId`, `hasTicket`), open cover requests I could take, and public events with roles |
|
||||
| GET | `/api/volunteers/events/:eventId` | user | `{ event, sessions, roles, totals }` — each role with `shift`, `needed`, `filled`, `full`, `mine`, `coverNeeded` (no names) |
|
||||
| POST | `/api/volunteers/roles/:id/sign-up` | user | `201 { slotId, ticket }`; `409 ROLE_FULL`, `409 SHIFT_CLASH`. Blocked in licence read-only mode |
|
||||
| DELETE | `/api/volunteers/slots/:id` | user | Withdraw (not after check-in or once the shift is over); the event's notify recipients are emailed |
|
||||
| POST | `/api/volunteers/slots/:id/swap` | user | `{ note? }` → `{ swapId }` — "can't make it"; the event's other volunteers are told |
|
||||
| DELETE | `/api/volunteers/swaps/:id` | user | Cancel my cover request |
|
||||
| POST | `/api/volunteers/swaps/:id/accept` | user | Take the place over at once → `{ slotId, role, event }`; `409 SWAP_CLOSED` if someone was first. Both volunteers and the event's notify recipients are told |
|
||||
| POST | `/api/volunteers/check-in` | user | `{ qr }` (`SERVE-<token>`) → `{ event, checkedIn: [{ role, kidsRoom }], already }` for my shifts on today. `404 BAD_QR`, `404 NOT_ROSTERED`, `400 NOT_TODAY`. A kids-room role starts a `KidsRoomDuty` (`fromRoster`) |
|
||||
| GET | `/api/volunteers/manage/events?all=1` | staff+ | Events with roles from the last 30 days on; `all=1` (supervisors) every upcoming event |
|
||||
| GET | `/api/volunteers/manage/events/:eventId?log=1` | staff+ | The roster with `volunteers: [{ slotId, name, phone, email, checkedInAt, checkInMethod, swapOpen, hasTicket }]` per role, and `totals: { needed, filled, checkedIn, fillRate }`. `log=1` writes the access log (`volunteer_roster`) |
|
||||
| GET | `/api/volunteers/manage/events/:eventId/qr` | staff+ | `{ token, qr }` — the check-in QR value (the page encodes `/serve/check-in?code=<qr>`) |
|
||||
| POST | `/api/volunteers/manage/slots/:id/check-in` | staff+ | `{ undo? }` — check in by hand (or undo, which ends rostered kids-room duty). Audited as `volunteer_checked_in_manual` |
|
||||
| GET / PUT | `/api/volunteers/manage/events/:eventId/settings` | supervisor+ | `{ optionId }` — the volunteer ticket: a main ticket covering the whole event, `'new'` for a free "Volunteer" ticket, `null` = off (default). Also returns the event's `kidsRooms`. Audited as `volunteer_settings_updated` |
|
||||
| POST | `/api/volunteers/manage/events/:eventId/roles` | supervisor+ | `{ name, needed, sessionId?, startsAt?, endsAt?, description?, kidsRoomId? }`. Audited as `volunteer_roles_changed`; blocked in read-only mode |
|
||||
| PUT / DELETE | `/api/volunteers/manage/roles/:id` | supervisor+ | Change / delete a role (delete refused once someone checked in; its volunteers are told) |
|
||||
| POST | `/api/volunteers/manage/roles/:id/assign` | supervisor+ | `{ userId }` — allowed beyond `needed`; the person is told. Audited as `volunteer_assigned` |
|
||||
| DELETE | `/api/volunteers/manage/slots/:id` | supervisor+ | Take someone off (not after check-in); they're told. Audited as `volunteer_removed` |
|
||||
| GET | `/api/volunteers/manage/hours?eventId=&from=&to=` | supervisor+ | `[{ userId, name, shifts, hours, events }]` — checked-in shifts × shift length, most first |
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VolunteerSwapStatus" AS ENUM ('open', 'accepted', 'cancelled');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_settings_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_roles_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_assigned';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_removed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'volunteer_checked_in_manual';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "volunteerCheckInToken" TEXT,
|
||||
ADD COLUMN "volunteerOptionId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "KidsRoomDuty" ADD COLUMN "fromRoster" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServingRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"needed" INTEGER NOT NULL DEFAULT 1,
|
||||
"startsAt" TIMESTAMP(3),
|
||||
"endsAt" TIMESTAMP(3),
|
||||
"kidsRoomId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ServingRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSlot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"assignedById" TEXT,
|
||||
"checkedInAt" TIMESTAMP(3),
|
||||
"checkedInById" TEXT,
|
||||
"checkInMethod" TEXT,
|
||||
"remindedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VolunteerSlot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VolunteerSwap" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slotId" TEXT,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toUserId" TEXT,
|
||||
"note" TEXT,
|
||||
"status" "VolunteerSwapStatus" NOT NULL DEFAULT 'open',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "VolunteerSwap_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_eventId_sessionId_idx" ON "ServingRole"("eventId", "sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServingRole_kidsRoomId_idx" ON "ServingRole"("kidsRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_userId_idx" ON "VolunteerSlot"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSlot_registrationId_idx" ON "VolunteerSlot"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VolunteerSlot_roleId_userId_key" ON "VolunteerSlot"("roleId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_roleId_status_idx" ON "VolunteerSwap"("roleId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VolunteerSwap_slotId_idx" ON "VolunteerSwap"("slotId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_volunteerCheckInToken_key" ON "Event"("volunteerCheckInToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServingRole" ADD CONSTRAINT "ServingRole_kidsRoomId_fkey" FOREIGN KEY ("kidsRoomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_assignedById_fkey" FOREIGN KEY ("assignedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSlot" ADD CONSTRAINT "VolunteerSlot_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_slotId_fkey" FOREIGN KEY ("slotId") REFERENCES "VolunteerSlot"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ServingRole"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VolunteerSwap" ADD CONSTRAINT "VolunteerSwap_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -158,6 +158,13 @@ model User {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -304,6 +311,14 @@ model Event {
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
|
||||
// Volunteer rosters (1.46.0): serving roles for the event or one of its sessions. When
|
||||
// `volunteerOptionId` is set (a main ticket of this event; off by default), each volunteer
|
||||
// gets a free place on it. `volunteerCheckInToken` is in the check-in QR code volunteers scan
|
||||
// on the day (made the first time the QR code is shown). Plain id, checked in utils/volunteers.js.
|
||||
servingRoles ServingRole[]
|
||||
volunteerOptionId String?
|
||||
volunteerCheckInToken String? @unique
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
@@ -399,6 +414,7 @@ model Registration {
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
volunteerSlots VolunteerSlot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -678,6 +694,11 @@ enum AdminAuditAction {
|
||||
kids_wrong_code
|
||||
kids_custody_notes_changed
|
||||
kids_offline_sync
|
||||
volunteer_settings_updated
|
||||
volunteer_roles_changed
|
||||
volunteer_assigned
|
||||
volunteer_removed
|
||||
volunteer_checked_in_manual
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1364,6 +1385,7 @@ model AllocationGroup {
|
||||
kidsCheckIns KidsCheckIn[]
|
||||
kidsAlerts KidsAlert[]
|
||||
kidsDuties KidsRoomDuty[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
@@ -1639,6 +1661,7 @@ model EventSession {
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
servingRoles ServingRole[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
@@ -2017,9 +2040,94 @@ model KidsRoomDuty {
|
||||
userId String
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
// Volunteer rosters (1.46.0): started by a rostered volunteer checking in, not "I'm on duty".
|
||||
fromRoster Boolean @default(false)
|
||||
room AllocationGroup @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId, occasion])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters (1.46.0) ───────────────────────────────────────────────
|
||||
|
||||
// A serving role (Welcome, Parking, Sound…) for one event — or, on a multi-session event, one
|
||||
// session — with the number of people needed. Shift times are optional (null = the session's or
|
||||
// event's times). A role linked to a kids room puts its volunteers on duty there when they check
|
||||
// in, so they count for the room's ratio.
|
||||
model ServingRole {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sessionId String?
|
||||
name String
|
||||
description String?
|
||||
needed Int @default(1)
|
||||
startsAt DateTime?
|
||||
endsAt DateTime?
|
||||
kidsRoomId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
kidsRoom AllocationGroup? @relation(fields: [kidsRoomId], references: [id], onDelete: SetNull)
|
||||
slots VolunteerSlot[]
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@index([eventId, sessionId])
|
||||
@@index([kidsRoomId])
|
||||
}
|
||||
|
||||
// A person serving in a role. `assignedById` is the supervisor who put them there (null = they
|
||||
// signed up). Withdrawing or being removed deletes the row; a swap moves it to the person who
|
||||
// takes it over. `registrationId` is the free volunteer place they were given, if any.
|
||||
model VolunteerSlot {
|
||||
id String @id @default(uuid())
|
||||
roleId String
|
||||
userId String
|
||||
assignedById String?
|
||||
checkedInAt DateTime?
|
||||
checkedInById String?
|
||||
// 'qr' (the volunteer scanned the check-in QR code) or 'manual' (ticked by staff)
|
||||
checkInMethod String?
|
||||
remindedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
user User @relation("VolunteerSlotUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignedBy User? @relation("VolunteerSlotAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull)
|
||||
checkedInBy User? @relation("VolunteerSlotCheckedInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
swaps VolunteerSwap[]
|
||||
|
||||
@@unique([roleId, userId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
enum VolunteerSwapStatus {
|
||||
open
|
||||
accepted
|
||||
cancelled
|
||||
}
|
||||
|
||||
// "Can't make it": the volunteer asks someone to take their place. It happens as soon as someone
|
||||
// accepts (the slot moves to them), and the event's notify recipients are told.
|
||||
model VolunteerSwap {
|
||||
id String @id @default(uuid())
|
||||
slotId String?
|
||||
roleId String
|
||||
fromUserId String?
|
||||
toUserId String?
|
||||
note String?
|
||||
status VolunteerSwapStatus @default(open)
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
slot VolunteerSlot? @relation(fields: [slotId], references: [id], onDelete: SetNull)
|
||||
role ServingRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
fromUser User? @relation("VolunteerSwapFrom", fields: [fromUserId], references: [id], onDelete: SetNull)
|
||||
toUser User? @relation("VolunteerSwapTo", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([roleId, status])
|
||||
@@index([slotId])
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -374,7 +375,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 +534,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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -26,6 +26,10 @@ const BLOCKED = [
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
// Volunteer rosters: new roles and new people on them (they can carry a free ticket).
|
||||
['POST', /^\/api\/volunteers\/roles\/[^/]+\/sign-up\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/events\/[^/]+\/roles\/?$/],
|
||||
['POST', /^\/api\/volunteers\/manage\/roles\/[^/]+\/assign\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
|
||||
@@ -184,6 +184,7 @@ app.get('/health', async (req, res) => {
|
||||
app.use('/api/hub', require('./routes/hubRoutes'));
|
||||
app.use('/api/campuses', require('./routes/campusRoutes'));
|
||||
app.use('/api/kids', require('./routes/kidsRoutes'));
|
||||
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -985,6 +986,32 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
|
||||
]},
|
||||
|
||||
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
|
||||
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
|
||||
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
|
||||
{ method:'POST', path:'/api/volunteers/roles/:id/sign-up', auth:'user', desc:'Sign up for a role. 409 ROLE_FULL when it\'s full, 409 SHIFT_CLASH when I serve elsewhere at the same time. Gives a free volunteer place when the event has one',
|
||||
responses:[{ status:201, desc:'Signed up', body:{ slotId:'s_1', ticket:true } }] },
|
||||
{ method:'DELETE', path:'/api/volunteers/slots/:id', auth:'user', desc:'Withdraw from a shift (not after checking in or once it\'s over). The event\'s notify recipients are emailed' },
|
||||
{ method:'POST', path:'/api/volunteers/slots/:id/swap', auth:'user', desc:'"Can\'t make it": ask for cover. The event\'s other volunteers are told; the request shows on everyone\'s Serving page', request:{ body:{ note:'Away that weekend' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/swaps/:id', auth:'user', desc:'Cancel my cover request' },
|
||||
{ method:'POST', path:'/api/volunteers/swaps/:id/accept', auth:'user', desc:'Take over someone\'s place. Happens at once (409 SWAP_CLOSED if someone was first); both volunteers and the event\'s notify recipients are told' },
|
||||
{ method:'POST', path:'/api/volunteers/check-in', auth:'user', desc:'Check in by scanning the event\'s volunteer QR code: every shift of mine on today is checked in; kids-room roles put me on duty in that room',
|
||||
request:{ body:{ qr:'SERVE-3f2a…' } }, responses:[{ status:200, desc:'Checked in', body:{ event:{ id:'e_1', title:'Sunday service' }, checkedIn:[{ role:'Kids', kidsRoom:'Toddlers' }], already:[] } }] },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events', auth:'staff+', desc:'Events with serving roles from the last 30 days on (?all=1, supervisors: every upcoming event). Campus-scoped' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId', auth:'staff+', desc:'The roster sheet: roles with volunteers, contact details, check-ins and fill rate. ?log=1 writes the access log' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/qr', auth:'staff+', desc:'The volunteer check-in QR code value for the event (made the first time)' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/slots/:id/check-in', auth:'staff+', desc:'Check a volunteer in by hand (undo:true to undo). Audited as volunteer_checked_in_manual' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'The volunteer ticket setting, the main tickets that can be it and the event\'s kids rooms' },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/events/:eventId/settings', auth:'supervisor+', desc:'Free volunteer place: optionId of a main ticket, "new" for a free "Volunteer" ticket, null for off (the default). Audited as volunteer_settings_updated', request:{ body:{ optionId:'new' } } },
|
||||
{ method:'POST', path:'/api/volunteers/manage/events/:eventId/roles', auth:'supervisor+', desc:'Add a serving role (sessionId required on multi-session events; shift times optional). Audited as volunteer_roles_changed',
|
||||
request:{ body:{ name:'Welcome', needed:4, sessionId:null, startsAt:'2026-11-08T07:30:00.000Z', endsAt:'2026-11-08T09:30:00.000Z', description:'Front doors', kidsRoomId:null } } },
|
||||
{ method:'PUT', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Change a role' },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/roles/:id', auth:'supervisor+', desc:'Delete a role (refused once someone checked in for it); its volunteers are told' },
|
||||
{ method:'POST', path:'/api/volunteers/manage/roles/:id/assign', auth:'supervisor+', desc:'Put someone on a role (allowed beyond the number needed); they\'re told. Audited as volunteer_assigned', request:{ body:{ userId:'u_1' } } },
|
||||
{ method:'DELETE', path:'/api/volunteers/manage/slots/:id', auth:'supervisor+', desc:'Take someone off a role (not after check-in); they\'re told. Audited as volunteer_removed' },
|
||||
{ method:'GET', path:'/api/volunteers/manage/hours?eventId=&from=&to=', auth:'supervisor+', desc:'Hours served per person (checked-in shifts × shift length), most first. Campus-scoped' },
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
|
||||
@@ -45,6 +45,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 () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const c = require('../controllers/volunteerController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
|
||||
// My account → Serving (anyone with an account can volunteer; only their own slots)
|
||||
router.get('/me', protect, c.getMine);
|
||||
router.get('/events/:eventId', protect, c.getEventRoles);
|
||||
router.post('/roles/:id/sign-up', protect, c.signUp);
|
||||
router.delete('/slots/:id', protect, c.withdraw);
|
||||
router.post('/slots/:id/swap', protect, c.requestSwap);
|
||||
router.delete('/swaps/:id', protect, c.cancelSwap);
|
||||
router.post('/swaps/:id/accept', protect, c.acceptSwap);
|
||||
router.post('/check-in', protect, c.checkIn);
|
||||
|
||||
// The roster on the day (staff)
|
||||
router.get('/manage/events', protect, staff, scopeList, c.listEvents);
|
||||
router.get('/manage/events/:eventId', protect, staff, scopeEvent(), c.getRoster);
|
||||
router.get('/manage/events/:eventId/qr', protect, staff, scopeEvent(), c.getQr);
|
||||
router.post('/manage/slots/:id/check-in', protect, staff, scopeEvent({ resolve: 'volunteerSlot' }), c.checkInManual);
|
||||
|
||||
// Roles, people, settings and reports (supervisors)
|
||||
router.get('/manage/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
|
||||
router.put('/manage/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
|
||||
router.post('/manage/events/:eventId/roles', protect, supervisor, scopeEvent({ need: 'manage' }), c.createRole);
|
||||
router.put('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.updateRole);
|
||||
router.delete('/manage/roles/:id', protect, supervisor, scopeEvent({ resolve: 'servingRole', need: 'manage' }), c.deleteRole);
|
||||
router.post('/manage/roles/:id/assign', protect, supervisor, scopeEvent({ resolve: 'servingRole' }), c.assign);
|
||||
router.delete('/manage/slots/:id', protect, supervisor, scopeEvent({ resolve: 'volunteerSlot' }), c.remove);
|
||||
router.get('/manage/hours', protect, supervisor, scopeList, c.hours);
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,6 +17,7 @@ const ACCESS_ACTIONS = {
|
||||
impersonation: 'Viewed the site as another user',
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,8 @@ const RESOLVERS = {
|
||||
},
|
||||
kidsCheckIn: async id => shape((await prisma.kidsCheckIn.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
kidsAlert: async id => shape((await prisma.kidsAlert.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
servingRole: async id => shape((await prisma.servingRole.findUnique({ where: { id }, select: { event: EV } }))?.event),
|
||||
volunteerSlot: async id => shape((await prisma.volunteerSlot.findUnique({ where: { id }, select: { role: { select: { event: EV } } } }))?.role?.event),
|
||||
};
|
||||
|
||||
/** The event a record belongs to (see RESOLVERS), or null. */
|
||||
|
||||
@@ -584,7 +584,7 @@ async function pageParent({ checkInId, message, staffUserId }, db = prisma) {
|
||||
async function board({ eventId, sessionId = null, now = new Date() }, db = prisma) {
|
||||
const event = await loadKidsEvent(eventId, db);
|
||||
const { occasion, sessionId: sid } = occasionFor(event, event.sessions, sessionId, now);
|
||||
const [rooms, checkIns, duties, alerts] = await Promise.all([
|
||||
const [rooms, checkIns, duties, alerts, rostered] = await Promise.all([
|
||||
roomsFor(event, db),
|
||||
db.kidsCheckIn.findMany({
|
||||
where: { eventId: event.id, occasion },
|
||||
@@ -598,6 +598,9 @@ async function board({ eventId, sessionId = null, now = new Date() }, db = prism
|
||||
}),
|
||||
db.kidsRoomDuty.findMany({ where: { occasion, endedAt: null, room: { setId: event.kidsRoomSetId } }, include: { user: { select: { id: true, name: true } } } }),
|
||||
db.kidsAlert.findMany({ where: { eventId: event.id, occasion }, orderBy: { createdAt: 'desc' }, take: 50, include: { createdBy: { select: { name: true } } } }),
|
||||
// Volunteer rosters (1.46.0): who is rostered in each room. They count as leaders on duty
|
||||
// once they check in (a KidsRoomDuty with fromRoster), like "I'm on duty".
|
||||
require('./volunteers').rosteredForRooms(event, occasion, db),
|
||||
]);
|
||||
const familyIds = [...new Set(checkIns.map(c => c.pickupCode.familyId).filter(Boolean))];
|
||||
const families = familyIds.length ? await db.family.findMany({ where: { id: { in: familyIds } }, include: FAMILY_INCLUDE }) : [];
|
||||
@@ -614,7 +617,7 @@ async function board({ eventId, sessionId = null, now = new Date() }, db = prism
|
||||
sessionLabel,
|
||||
rooms: rooms.map(r => {
|
||||
const leaders = duties.filter(d => d.roomId === r.id).map(d => d.user);
|
||||
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), rostered: rostered[r.id] || [], ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
|
||||
}),
|
||||
checkIns: checkIns.map(c => {
|
||||
const fam = famById[c.pickupCode.familyId];
|
||||
@@ -835,7 +838,7 @@ module.exports = {
|
||||
randomCode, normaliseCode, ageOn, fitsRoom, suggestRoom, roomStatus, stickerAlert, occasionFor, checkInMessage, listNames,
|
||||
childView, familyView, loadFamily, familyOfUser, createFamily, cleanChild, cleanAdult, custodyChanged, searchFamilies,
|
||||
loadKidsEvent, roomsFor, getSettings, saveSettings,
|
||||
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, sendCheckInMessages, pageParent,
|
||||
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, messageParent, sendCheckInMessages, pageParent,
|
||||
board, setDuty, moveRoom, activeForFamily, offlineSnapshot, syncOffline,
|
||||
removeFromFamily, exportFamily, runKidsRetention, previewKidsRetention,
|
||||
};
|
||||
|
||||
@@ -1513,6 +1513,7 @@ async function sendAttendeeFormLinkMessage(attendee) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEventNotifyEmails,
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
|
||||
@@ -460,7 +460,10 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
const { familyDeleted } = await require('./kids').removeFromFamily(tx, userId);
|
||||
await tx.kidsCheckIn.updateMany({ where: { checkedOutById: userId }, data: { checkedOutById: null } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted };
|
||||
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
|
||||
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
await clearTrustedDevices(userId);
|
||||
@@ -482,7 +485,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family] = await Promise.all([
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -507,6 +510,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
require('./kids').exportFamily(userId, db),
|
||||
require('./volunteers').exportVolunteering(userId, db),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -549,6 +553,7 @@ async function exportUserData(userId, db = prisma) {
|
||||
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
|
||||
dataRequests: requests,
|
||||
kidsFamily: family,
|
||||
volunteering,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -605,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) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Volunteer rosters (1.46.0). Who serves where at an event:
|
||||
*
|
||||
* - Serving roles (Welcome, Parking, Kids, Sound…) belong to one event, or — on a multi-session
|
||||
* event — to one session, with the number of people needed and optional shift times (default:
|
||||
* the session's or event's times). Rosters never repeat: each event/session has its own roles.
|
||||
* - Anyone with an account can sign up for a role that isn't full; supervisors can also assign
|
||||
* people (even beyond the number needed). Nobody can be in two roles whose shifts overlap.
|
||||
* - Volunteers get a reminder the day before their shift. "Can't make it" asks for cover: the
|
||||
* request shows on the Serving page and goes to the event's other volunteers, and the place
|
||||
* moves as soon as someone accepts — the event's notify recipients are told, nobody approves.
|
||||
* - On the day volunteers check themselves in by scanning the event's volunteer QR code (or staff
|
||||
* tick them in). A role linked to a kids room puts its volunteers on duty in that room when they
|
||||
* check in, so they count for the room's leader-to-children ratio.
|
||||
* - A free volunteer place on a ticket of the event is off by default; a supervisor turns it on
|
||||
* per event by choosing the ticket. Volunteers who already have a ticket keep theirs.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const sessionsUtil = require('./sessions');
|
||||
|
||||
const MAX_NEEDED = 200;
|
||||
const MAX_ROLES = 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
// Reminders go out this long before a shift, to people who signed up before then.
|
||||
const REMIND_BEFORE_MS = 24 * HOUR;
|
||||
// What the check-in QR code holds: the prefix, then the event's token.
|
||||
const QR_PREFIX = 'SERVE-';
|
||||
|
||||
function volunteerError(message, statusCode = 400, code = undefined) {
|
||||
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A role's shift: its own times, else its session's, else the event's. */
|
||||
function shiftOf(role, event) {
|
||||
const session = role.session || (role.sessionId ? (event.sessions || []).find(s => s.id === role.sessionId) : null);
|
||||
return {
|
||||
startsAt: new Date(role.startsAt || session?.startsAt || event.startDate),
|
||||
endsAt: new Date(role.endsAt || session?.endsAt || event.endDate),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two shifts overlap (touching ends don't count: 08:00–10:00 and 10:00–12:00 are fine). */
|
||||
function overlaps(a, b) {
|
||||
return new Date(a.startsAt) < new Date(b.endsAt) && new Date(b.startsAt) < new Date(a.endsAt);
|
||||
}
|
||||
|
||||
/** Hours in a shift, to the nearest quarter hour. */
|
||||
function shiftHours(shift) {
|
||||
const h = (new Date(shift.endsAt) - new Date(shift.startsAt)) / HOUR;
|
||||
return h > 0 ? Math.round(h * 4) / 4 : 0;
|
||||
}
|
||||
|
||||
/** { needed, filled, open, full } for a role with `filled` volunteers. */
|
||||
function fillOf(needed, filled) {
|
||||
return { needed, filled, open: Math.max(0, needed - filled), full: filled >= needed };
|
||||
}
|
||||
|
||||
/** Volunteers can check in on the day(s) of their shift, until it ends. */
|
||||
function checkInOpen(shift, now = new Date()) {
|
||||
const today = sessionsUtil.dayKey(now);
|
||||
return today >= sessionsUtil.dayKey(shift.startsAt) && today <= sessionsUtil.dayKey(shift.endsAt) && new Date(now) <= new Date(shift.endsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a slot's reminder is due: not sent yet, the shift starts within the next 24 hours, and
|
||||
* the person signed up more than half an hour ago (someone who just signed up doesn't need one).
|
||||
*/
|
||||
function reminderDue({ remindedAt, createdAt }, shift, now = new Date()) {
|
||||
if (remindedAt) return false;
|
||||
const t = new Date(now).getTime();
|
||||
const start = new Date(shift.startsAt).getTime();
|
||||
return start > t && start - t <= REMIND_BEFORE_MS && t - new Date(createdAt).getTime() > HOUR / 2;
|
||||
}
|
||||
|
||||
/** The kids room occasion a check-in counts for (see utils/kids.js occasionFor): the session, or today. */
|
||||
function kidsOccasion(role, now = new Date()) {
|
||||
return role.sessionId || sessionsUtil.dayKey(now);
|
||||
}
|
||||
|
||||
/** Ticket options only staff hand out — the kids check-in and volunteer tickets — never sold publicly. */
|
||||
function reservedOptionIds(event) {
|
||||
return [event?.kidsOptionId, event?.volunteerOptionId].filter(Boolean);
|
||||
}
|
||||
|
||||
/** The event's options without the reserved ones (for public event pages and lists). */
|
||||
function publicOptions(event) {
|
||||
const reserved = new Set(reservedOptionIds(event));
|
||||
return (event?.eventOptions || []).filter(o => !reserved.has(o.id));
|
||||
}
|
||||
|
||||
function fmtWhen(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return new Date(d).toLocaleTimeString('en-GB', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** "Sat 7 Nov, 08:00–10:30" (the end date is added when the shift runs past midnight). */
|
||||
function shiftText(shift) {
|
||||
const sameDay = sessionsUtil.dayKey(shift.startsAt) === sessionsUtil.dayKey(shift.endsAt);
|
||||
return `${fmtWhen(shift.startsAt)}–${sameDay ? fmtTime(shift.endsAt) : fmtWhen(shift.endsAt)}`;
|
||||
}
|
||||
|
||||
function cleanText(v, max) {
|
||||
return String(v ?? '').trim().slice(0, max);
|
||||
}
|
||||
|
||||
// ─── Loading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_SELECT = {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true, location: true, campusId: true,
|
||||
kidsCheckIn: true, kidsRoomSetId: true, kidsOptionId: true, volunteerOptionId: true, volunteerCheckInToken: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
|
||||
async function loadEvent(eventId, db = prisma) {
|
||||
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, select: EVENT_SELECT });
|
||||
if (!event) throw volunteerError('Event not found', 404);
|
||||
return event;
|
||||
}
|
||||
|
||||
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true };
|
||||
|
||||
const ROLE_INCLUDE = {
|
||||
session: true,
|
||||
kidsRoom: { select: { id: true, name: true } },
|
||||
slots: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { user: { select: USER_SELECT }, assignedBy: { select: { name: true } }, checkedInBy: { select: { name: true } } },
|
||||
},
|
||||
swaps: { where: { status: 'open' }, orderBy: { createdAt: 'asc' } },
|
||||
};
|
||||
|
||||
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
|
||||
|
||||
/**
|
||||
* A role for the API. `staff` adds the volunteers (names, contact, check-ins); others see only
|
||||
* how full it is, whether they're in it, and open cover requests.
|
||||
*/
|
||||
function roleView(role, event, { staff = false, userId = null, now = new Date() } = {}) {
|
||||
const shift = shiftOf(role, event);
|
||||
const sessions = sessionsUtil.activeSessions(event.sessions);
|
||||
const mine = userId ? role.slots.find(s => s.userId === userId) : null;
|
||||
const swapBySlot = Object.fromEntries(role.swaps.map(w => [w.slotId, w]));
|
||||
return {
|
||||
id: role.id,
|
||||
eventId: role.eventId,
|
||||
sessionId: role.sessionId,
|
||||
sessionLabel: role.session ? sessionsUtil.sessionLabel(role.session, sessions) : null,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
startsAt: role.startsAt,
|
||||
endsAt: role.endsAt,
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
kidsRoom: role.kidsRoom || null,
|
||||
order: role.order,
|
||||
past: new Date(shift.endsAt) < new Date(now),
|
||||
...fillOf(role.needed, role.slots.length),
|
||||
mine: mine ? { slotId: mine.id, checkedInAt: mine.checkedInAt, swapId: swapBySlot[mine.id]?.id || null } : null,
|
||||
coverNeeded: role.swaps.filter(w => w.fromUserId !== userId).map(w => ({ swapId: w.id, note: w.note || '' })),
|
||||
...(staff ? {
|
||||
volunteers: role.slots.map(s => ({
|
||||
slotId: s.id,
|
||||
userId: s.user.id,
|
||||
name: s.user.name,
|
||||
phone: s.user.phoneNumber || null,
|
||||
email: realEmail(s.user.email),
|
||||
assignedBy: s.assignedBy?.name || null,
|
||||
checkedInAt: s.checkedInAt,
|
||||
checkedInBy: s.checkedInBy?.name || null,
|
||||
checkInMethod: s.checkInMethod,
|
||||
swapOpen: !!swapBySlot[s.id],
|
||||
hasTicket: !!s.registrationId,
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An event's roster: its roles in order (by session, then shift start, then order). */
|
||||
async function roster(eventId, { staff = false, userId = null, now = new Date() } = {}, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const roles = await db.servingRole.findMany({ where: { eventId: event.id }, include: ROLE_INCLUDE });
|
||||
const views = roles.map(r => roleView(r, event, { staff, userId, now }))
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt) || a.order - b.order || a.name.localeCompare(b.name));
|
||||
const totals = views.reduce((t, r) => ({ needed: t.needed + r.needed, filled: t.filled + Math.min(r.filled, r.needed), checkedIn: t.checkedIn + (r.volunteers || []).filter(v => v.checkedInAt).length }), { needed: 0, filled: 0, checkedIn: 0 });
|
||||
return {
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, scheduleType: event.scheduleType, location: event.location, kidsCheckIn: event.kidsCheckIn },
|
||||
sessions: sessionsUtil.activeSessions(event.sessions).map(s => ({ id: s.id, label: sessionsUtil.sessionLabel(s, event.sessions), startsAt: s.startsAt, endsAt: s.endsAt })),
|
||||
roles: views,
|
||||
totals: { ...totals, fillRate: totals.needed ? Math.round((totals.filled / totals.needed) * 100) : null },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Roles (supervisors) ──────────────────────────────────────────────────────
|
||||
|
||||
function parseTime(v, what) {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) throw volunteerError(`Enter a valid ${what}.`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Check and clean a role from the request (`existing` when changing one). */
|
||||
async function cleanRole(event, input, existing = null, db = prisma) {
|
||||
const b = input || {};
|
||||
const data = {};
|
||||
if (b.name !== undefined || !existing) {
|
||||
data.name = cleanText(b.name, 60);
|
||||
if (!data.name) throw volunteerError('Enter a name for the role (e.g. Welcome, Parking, Sound).');
|
||||
}
|
||||
if (b.description !== undefined) data.description = cleanText(b.description, 500) || null;
|
||||
if (b.needed !== undefined || !existing) {
|
||||
const n = parseInt(b.needed ?? 1, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > MAX_NEEDED) throw volunteerError(`The number of people needed must be from 1 to ${MAX_NEEDED}.`);
|
||||
data.needed = n;
|
||||
}
|
||||
if (b.sessionId !== undefined || !existing) {
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const session = event.sessions.find(s => s.id === b.sessionId && !s.cancelledAt);
|
||||
if (!session) throw volunteerError('Choose the session this role is for.');
|
||||
data.sessionId = session.id;
|
||||
} else data.sessionId = null;
|
||||
}
|
||||
const startsAt = parseTime(b.startsAt, 'start time');
|
||||
const endsAt = parseTime(b.endsAt, 'end time');
|
||||
if (startsAt !== undefined) data.startsAt = startsAt;
|
||||
if (endsAt !== undefined) data.endsAt = endsAt;
|
||||
const shift = shiftOf({ ...existing, ...data, session: undefined }, event);
|
||||
if (shift.endsAt <= shift.startsAt) throw volunteerError('The shift has to end after it starts.');
|
||||
if (b.kidsRoomId !== undefined) {
|
||||
if (!b.kidsRoomId) data.kidsRoomId = null;
|
||||
else {
|
||||
const room = event.kidsRoomSetId ? await db.allocationGroup.findFirst({ where: { id: String(b.kidsRoomId), setId: event.kidsRoomSetId } }) : null;
|
||||
if (!room) throw volunteerError('Choose one of this event\'s kids rooms (set up under Kids check-in setup).');
|
||||
data.kidsRoomId = room.id;
|
||||
}
|
||||
}
|
||||
if (b.order !== undefined) data.order = Math.max(0, Math.min(1000, parseInt(b.order, 10) || 0));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createRole(eventId, input, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
if (await db.servingRole.count({ where: { eventId: event.id } }) >= MAX_ROLES) throw volunteerError(`An event can have up to ${MAX_ROLES} serving roles.`);
|
||||
const data = await cleanRole(event, input, null, db);
|
||||
if (input?.order === undefined) data.order = await db.servingRole.count({ where: { eventId: event.id, sessionId: data.sessionId } });
|
||||
return db.servingRole.create({ data: { ...data, eventId: event.id } });
|
||||
}
|
||||
|
||||
async function updateRole(roleId, input, db = prisma) {
|
||||
const existing = await db.servingRole.findUnique({ where: { id: String(roleId || '') } });
|
||||
if (!existing) throw volunteerError('Role not found', 404);
|
||||
const event = await loadEvent(existing.eventId, db);
|
||||
const data = await cleanRole(event, input, existing, db);
|
||||
if (data.sessionId !== undefined && data.sessionId !== existing.sessionId && await db.volunteerSlot.count({ where: { roleId: existing.id } })) {
|
||||
throw volunteerError('People have signed up for this role, so it can\'t move to another session — add a new role there instead.');
|
||||
}
|
||||
return db.servingRole.update({ where: { id: existing.id }, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role. Refused once someone has checked in for it (it counts for hours served).
|
||||
* Returns the volunteers who were on it, so they can be told.
|
||||
*/
|
||||
async function deleteRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { slots: { include: { user: { select: USER_SELECT } } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
if (role.slots.some(s => s.checkedInAt)) throw volunteerError('Someone has already checked in for this role, so it stays on the roster (it counts for hours served).');
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of role.slots) await releasePlace(tx, s);
|
||||
await tx.servingRole.delete({ where: { id: role.id } });
|
||||
});
|
||||
return { role, event, volunteers: role.slots.map(s => s.user) };
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The event's volunteer settings and the tickets that can be the volunteer ticket. */
|
||||
async function getSettings(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
const [options, rooms] = await Promise.all([
|
||||
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
|
||||
event.kidsRoomSetId ? db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, select: { id: true, name: true }, orderBy: { order: 'asc' } }) : [],
|
||||
]);
|
||||
return {
|
||||
event: { id: event.id, title: event.title, volunteerOptionId: event.volunteerOptionId },
|
||||
options: options.filter(o => o.id !== event.kidsOptionId),
|
||||
kidsRooms: rooms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the free volunteer place on or off. `optionId: 'new'` adds a free "Volunteer" main ticket
|
||||
* (staff-only: it never shows publicly); null switches it off (places already given stay).
|
||||
*/
|
||||
async function saveSettings(eventId, { optionId }, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let next = null;
|
||||
if (optionId === 'new') {
|
||||
next = (await db.eventOption.create({ data: { eventId: event.id, name: 'Volunteer', price: 0, isMainTicket: true, sessionMode: 'all' } })).id;
|
||||
} else if (optionId) {
|
||||
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
|
||||
if (!opt || opt.id === event.kidsOptionId) throw volunteerError('Choose a main ticket of this event that covers the whole event (not the kids ticket).');
|
||||
next = opt.id;
|
||||
}
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerOptionId: next } });
|
||||
return getSettings(event.id, db);
|
||||
}
|
||||
|
||||
/** The event's check-in QR value (the token is made the first time). */
|
||||
async function checkInQr(eventId, db = prisma) {
|
||||
const event = await loadEvent(eventId, db);
|
||||
let token = event.volunteerCheckInToken;
|
||||
if (!token) {
|
||||
token = crypto.randomBytes(12).toString('hex');
|
||||
await db.event.update({ where: { id: event.id }, data: { volunteerCheckInToken: token } });
|
||||
}
|
||||
return { token, qr: QR_PREFIX + token };
|
||||
}
|
||||
|
||||
// ─── The free volunteer place ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Give a volunteer a free place on the event's volunteer ticket, when that's switched on and they
|
||||
* have no ticket for the event yet. Returns the registration id (shared by their slots), or null.
|
||||
*/
|
||||
async function givePlace(tx, event, userId) {
|
||||
if (!event.volunteerOptionId) return null;
|
||||
const shared = await tx.volunteerSlot.findFirst({ where: { userId, registrationId: { not: null }, role: { eventId: event.id }, registration: { status: { not: 'cancelled' } } }, select: { registrationId: true } });
|
||||
if (shared) return shared.registrationId;
|
||||
const hasTicket = await tx.ticket.count({ where: { eventId: event.id, OR: [{ userId }, { holderUserId: userId }], registrationOption: { registration: { status: { not: 'cancelled' } } } } });
|
||||
if (hasTicket) return null;
|
||||
const registration = await tx.registration.create({ data: { userId, eventId: event.id, status: 'paid' } });
|
||||
const line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.volunteerOptionId, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
|
||||
await tx.ticket.create({ data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId, eventId: event.id, quantity: 1, emailSent: true } });
|
||||
return registration.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a slot goes (withdrawn, removed, swapped away): cancel its free place if no other slot of
|
||||
* the person at the event still uses it and the ticket was never scanned.
|
||||
*/
|
||||
async function releasePlace(tx, slot) {
|
||||
if (!slot.registrationId) return;
|
||||
const others = await tx.volunteerSlot.count({ where: { registrationId: slot.registrationId, id: { not: slot.id }, userId: slot.userId } });
|
||||
if (others) return;
|
||||
const scanned = await tx.ticketUsage.count({ where: { ticket: { registrationOption: { registrationId: slot.registrationId } } } });
|
||||
if (scanned) return;
|
||||
await tx.registration.updateMany({ where: { id: slot.registrationId, status: { not: 'cancelled' } }, data: { status: 'cancelled' } });
|
||||
}
|
||||
|
||||
// ─── Signing up ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadRole(roleId, db = prisma) {
|
||||
const role = await db.servingRole.findUnique({ where: { id: String(roleId || '') }, include: { session: true, slots: { select: { id: true, userId: true } } } });
|
||||
if (!role) throw volunteerError('Role not found', 404);
|
||||
return role;
|
||||
}
|
||||
|
||||
/** Refuse a role whose shift is over, a cancelled session, or an inactive event. */
|
||||
function assertOpen(role, event, now = new Date()) {
|
||||
if (!event.isActive) throw volunteerError('This event isn\'t open any more.');
|
||||
if (role.session?.cancelledAt) throw volunteerError('That session was cancelled.');
|
||||
if (shiftOf(role, event).endsAt <= new Date(now)) throw volunteerError('This shift is already over.');
|
||||
}
|
||||
|
||||
/** Refuse when the person serves in another role at the same time. */
|
||||
async function assertFree(tx, event, role, userId) {
|
||||
const shift = shiftOf(role, event);
|
||||
const theirs = await tx.volunteerSlot.findMany({ where: { userId, roleId: { not: role.id }, role: { eventId: event.id } }, include: { role: { include: { session: true } } } });
|
||||
const clash = theirs.find(s => overlaps(shiftOf(s.role, event), shift));
|
||||
if (clash) throw volunteerError(`${clash.role.name} (${shiftText(shiftOf(clash.role, event))}) is at the same time.`, 409, 'SHIFT_CLASH');
|
||||
}
|
||||
|
||||
/**
|
||||
* Put someone on a role: they signed up (`assignedById` null — refused when the role is full), or
|
||||
* a supervisor assigned them (allowed beyond the number needed).
|
||||
*/
|
||||
async function signUp({ roleId, userId, assignedById = null, now = new Date() }, db = prisma) {
|
||||
const role = await loadRole(roleId, db);
|
||||
const event = await loadEvent(role.eventId, db);
|
||||
assertOpen(role, event, now);
|
||||
if (role.slots.some(s => s.userId === userId)) throw volunteerError(assignedById ? 'This person is already on this role.' : 'You\'re already serving in this role.', 409);
|
||||
const slot = await db.$transaction(async tx => {
|
||||
if (!assignedById && await tx.volunteerSlot.count({ where: { roleId: role.id } }) >= role.needed) throw volunteerError('This role is full — thank you for offering!', 409, 'ROLE_FULL');
|
||||
await assertFree(tx, event, role, userId);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
return tx.volunteerSlot.create({ data: { roleId: role.id, userId, assignedById, registrationId } });
|
||||
});
|
||||
return { slot, role, event };
|
||||
}
|
||||
|
||||
async function loadSlot(slotId, db = prisma) {
|
||||
const slot = await db.volunteerSlot.findUnique({ where: { id: String(slotId || '') }, include: { user: { select: USER_SELECT }, role: { include: { session: true } } } });
|
||||
if (!slot) throw volunteerError('Not found', 404);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Take someone off a role (they withdrew, or a supervisor removed them). Not after check-in. */
|
||||
async function leave({ slotId, userId = null }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (userId && slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
if (slot.checkedInAt) throw volunteerError(`${userId ? 'You\'ve' : `${slot.user.name} has`} already checked in for this shift.`);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (userId && shiftOf(slot.role, event).endsAt <= new Date()) throw volunteerError('This shift is already over.');
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: slot.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
await releasePlace(tx, slot);
|
||||
await tx.volunteerSlot.delete({ where: { id: slot.id } });
|
||||
});
|
||||
return { slot, event };
|
||||
}
|
||||
|
||||
// ─── Swaps ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "Can't make it": ask for someone to take over the slot. One open request per slot. */
|
||||
async function requestSwap({ slotId, userId, note, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
if (slot.userId !== userId) throw volunteerError('Not found', 404);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
if (slot.checkedInAt) throw volunteerError('You\'ve already checked in for this shift.');
|
||||
const open = await db.volunteerSwap.findFirst({ where: { slotId: slot.id, status: 'open' } });
|
||||
if (open) return { swap: open, slot, event, created: false };
|
||||
const swap = await db.volunteerSwap.create({ data: { slotId: slot.id, roleId: slot.roleId, fromUserId: userId, note: cleanText(note, 300) || null } });
|
||||
return { swap, slot, event, created: true };
|
||||
}
|
||||
|
||||
async function cancelSwap({ swapId, userId }, db = prisma) {
|
||||
const out = await db.volunteerSwap.updateMany({ where: { id: String(swapId || ''), fromUserId: userId, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date() } });
|
||||
if (!out.count) throw volunteerError('Not found', 404);
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a slot someone can't make. Happens at once: the slot moves to the person accepting
|
||||
* (their reminder and free place are theirs now). Returns { swap, slot, from, to, event }.
|
||||
*/
|
||||
async function acceptSwap({ swapId, userId, now = new Date() }, db = prisma) {
|
||||
const swap = await db.volunteerSwap.findUnique({ where: { id: String(swapId || '') } });
|
||||
if (!swap || swap.status !== 'open' || !swap.slotId) throw volunteerError('Someone has already taken this place, or it isn\'t needed any more.', 409, 'SWAP_CLOSED');
|
||||
if (swap.fromUserId === userId) throw volunteerError('This is your own request — cancel it instead.');
|
||||
const slot = await loadSlot(swap.slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
assertOpen(slot.role, event, now);
|
||||
await db.$transaction(async tx => {
|
||||
// Claim the request first, so two people accepting at once can't both take it.
|
||||
const claimed = await tx.volunteerSwap.updateMany({ where: { id: swap.id, status: 'open' }, data: { status: 'accepted', toUserId: userId, resolvedAt: new Date(now) } });
|
||||
if (!claimed.count) throw volunteerError('Someone has just taken this place.', 409, 'SWAP_CLOSED');
|
||||
if (await tx.volunteerSlot.count({ where: { roleId: slot.roleId, userId } })) throw volunteerError('You\'re already serving in this role.', 409);
|
||||
await assertFree(tx, event, slot.role, userId);
|
||||
await releasePlace(tx, slot);
|
||||
const registrationId = await givePlace(tx, event, userId);
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { userId, assignedById: null, registrationId, remindedAt: null } });
|
||||
});
|
||||
const to = await db.user.findUnique({ where: { id: userId }, select: USER_SELECT });
|
||||
return { swap, slot, from: slot.user, to, event };
|
||||
}
|
||||
|
||||
/** Open cover requests the person could take (not their own, not a role they're in, not over). */
|
||||
async function openSwaps({ userId, now = new Date() }, db = prisma) {
|
||||
const swaps = await db.volunteerSwap.findMany({
|
||||
where: { status: 'open', fromUserId: { not: userId }, role: { event: { isActive: true, endDate: { gte: now } }, slots: { none: { userId } } } },
|
||||
include: { role: { include: { session: true, event: { select: EVENT_SELECT } } }, fromUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return swaps
|
||||
.map(w => ({ w, shift: shiftOf(w.role, w.role.event) }))
|
||||
.filter(({ shift }) => shift.endsAt > new Date(now))
|
||||
.map(({ w, shift }) => ({
|
||||
swapId: w.id, note: w.note || '', from: (w.fromUser?.name || '').split(' ')[0] || null,
|
||||
role: { id: w.role.id, name: w.role.name, sessionLabel: w.role.session ? sessionsUtil.sessionLabel(w.role.session, w.role.event.sessions) : null },
|
||||
event: { id: w.role.event.id, title: w.role.event.title },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift) },
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Check-in ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Put a checked-in volunteer of a kids-room role on duty in that room for the occasion. */
|
||||
async function startKidsDuty(tx, event, role, userId, now) {
|
||||
if (!role.kidsRoomId || !event.kidsCheckIn || !event.kidsRoomSetId) return false;
|
||||
const room = await tx.allocationGroup.findFirst({ where: { id: role.kidsRoomId, setId: event.kidsRoomSetId }, select: { id: true } });
|
||||
if (!room) return false;
|
||||
const occasion = kidsOccasion(role, now);
|
||||
const already = await tx.kidsRoomDuty.findFirst({ where: { userId, occasion, roomId: room.id, endedAt: null } });
|
||||
if (already) return true;
|
||||
// One room at a time, as with "I'm on duty".
|
||||
await tx.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
await tx.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId, fromRoster: true, startedAt: new Date(now) } });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function markCheckedIn(tx, event, slot, { by, method, now }) {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: new Date(now), checkedInById: by, checkInMethod: method } });
|
||||
return startKidsDuty(tx, event, slot.role, slot.userId, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* A volunteer scanned the event's check-in QR code: check in every slot of theirs whose shift is
|
||||
* on today. Returns { event, checkedIn: [{ role, kidsRoom }], already }.
|
||||
*/
|
||||
async function checkInWithQr({ qr, userId, now = new Date() }, db = prisma) {
|
||||
const token = String(qr || '').trim().replace(new RegExp(`^${QR_PREFIX}`, 'i'), '');
|
||||
const found = token ? await db.event.findUnique({ where: { volunteerCheckInToken: token }, select: { id: true } }) : null;
|
||||
if (!found) throw volunteerError('That isn\'t a volunteer check-in code from this site.', 404, 'BAD_QR');
|
||||
const event = await loadEvent(found.id, db);
|
||||
const slots = await db.volunteerSlot.findMany({ where: { userId, role: { eventId: event.id } }, include: { role: { include: { session: true, kidsRoom: { select: { name: true } } } } } });
|
||||
if (!slots.length) throw volunteerError(`You're not on the volunteer roster for ${event.title}.`, 404, 'NOT_ROSTERED');
|
||||
const today = slots.filter(s => !s.role.session?.cancelledAt && checkInOpen(shiftOf(s.role, event), now));
|
||||
if (!today.length) {
|
||||
const next = slots.map(s => shiftOf(s.role, event)).filter(sh => sh.endsAt > new Date(now)).sort((a, b) => a.startsAt - b.startsAt)[0];
|
||||
throw volunteerError(next ? `Check-in opens on the day of your shift (${shiftText(next)}).` : 'Your shifts at this event are over.', 400, 'NOT_TODAY');
|
||||
}
|
||||
const checkedIn = [];
|
||||
const already = [];
|
||||
await db.$transaction(async tx => {
|
||||
for (const s of today) {
|
||||
if (s.checkedInAt) { already.push(s.role.name); continue; }
|
||||
const onDuty = await markCheckedIn(tx, event, s, { by: userId, method: 'qr', now });
|
||||
checkedIn.push({ role: s.role.name, kidsRoom: onDuty ? s.role.kidsRoom?.name || null : null });
|
||||
}
|
||||
});
|
||||
return { event: { id: event.id, title: event.title }, checkedIn, already };
|
||||
}
|
||||
|
||||
/** Staff tick a volunteer in (or undo it, which also ends their rostered kids-room duty). */
|
||||
async function checkInManual({ slotId, staffUserId, undo = false, now = new Date() }, db = prisma) {
|
||||
const slot = await loadSlot(slotId, db);
|
||||
const event = await loadEvent(slot.role.eventId, db);
|
||||
if (undo) {
|
||||
if (!slot.checkedInAt) return { slot, changed: false };
|
||||
await db.$transaction(async tx => {
|
||||
await tx.volunteerSlot.update({ where: { id: slot.id }, data: { checkedInAt: null, checkedInById: null, checkInMethod: null } });
|
||||
if (slot.role.kidsRoomId) await tx.kidsRoomDuty.updateMany({ where: { userId: slot.userId, roomId: slot.role.kidsRoomId, fromRoster: true, endedAt: null }, data: { endedAt: new Date(now) } });
|
||||
});
|
||||
return { slot, changed: true };
|
||||
}
|
||||
if (slot.checkedInAt) return { slot, changed: false };
|
||||
if (!checkInOpen(shiftOf(slot.role, event), now) && new Date(now) < shiftOf(slot.role, event).startsAt) {
|
||||
throw volunteerError(`${slot.user.name} can be checked in on the day of the shift (${shiftText(shiftOf(slot.role, event))}).`);
|
||||
}
|
||||
await db.$transaction(tx => markCheckedIn(tx, event, slot, { by: staffUserId, method: 'manual', now }));
|
||||
return { slot, changed: true };
|
||||
}
|
||||
|
||||
/** For the kids board: who is rostered in each room for an occasion, and whether they've checked in. */
|
||||
async function rosteredForRooms(event, occasion, db = prisma) {
|
||||
if (!event.kidsRoomSetId) return {};
|
||||
const roles = await db.servingRole.findMany({
|
||||
where: { eventId: event.id, kidsRoom: { setId: event.kidsRoomSetId }, ...(event.scheduleType === 'sessions' ? { sessionId: occasion } : {}) },
|
||||
include: { session: true, slots: { include: { user: { select: { id: true, name: true } } } } },
|
||||
});
|
||||
const out = {};
|
||||
for (const r of roles) {
|
||||
// Single-date events: only roles whose shift is on that day.
|
||||
if (event.scheduleType !== 'sessions') {
|
||||
const shift = shiftOf(r, event);
|
||||
if (occasion < sessionsUtil.dayKey(shift.startsAt) || occasion > sessionsUtil.dayKey(shift.endsAt)) continue;
|
||||
}
|
||||
for (const s of r.slots) {
|
||||
(out[r.kidsRoomId] ||= []).push({ userId: s.user.id, name: s.user.name, checkedIn: !!s.checkedInAt });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── My serving ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The person's upcoming (and the last week's) slots, with open swap requests. */
|
||||
async function mySlots({ userId, now = new Date() }, db = prisma) {
|
||||
const since = new Date(new Date(now).getTime() - 7 * 24 * HOUR);
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId, role: { event: { endDate: { gte: since } } } },
|
||||
include: { role: { include: { session: true, kidsRoom: { select: { name: true } }, event: { select: EVENT_SELECT } } }, swaps: { where: { status: 'open' } } },
|
||||
});
|
||||
return slots
|
||||
.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return {
|
||||
slotId: s.id,
|
||||
role: { id: s.role.id, name: s.role.name, description: s.role.description || '', sessionLabel: s.role.session ? sessionsUtil.sessionLabel(s.role.session, s.role.event.sessions) : null, kidsRoom: s.role.kidsRoom?.name || null },
|
||||
event: { id: s.role.event.id, title: s.role.event.title, location: s.role.event.location },
|
||||
shift: { startsAt: shift.startsAt, endsAt: shift.endsAt, text: shiftText(shift), hours: shiftHours(shift) },
|
||||
checkedInAt: s.checkedInAt,
|
||||
canCheckIn: !s.checkedInAt && checkInOpen(shift, now),
|
||||
past: shift.endsAt < new Date(now),
|
||||
swapId: s.swaps[0]?.id || null,
|
||||
hasTicket: !!s.registrationId,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(a.shift.startsAt) - new Date(b.shift.startsAt));
|
||||
}
|
||||
|
||||
/** Upcoming events with serving roles (for "Serve at an event"). */
|
||||
async function eventsWithRoles({ now = new Date(), where = {} } = {}, db = prisma) {
|
||||
return db.event.findMany({
|
||||
where: { AND: [{ isActive: true, endDate: { gte: now }, servingRoles: { some: {} } }, where] },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campusId: true, isHidden: true, _count: { select: { servingRoles: true } } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hours served per person over the events matching `eventWhere` (checked-in shifts only), most
|
||||
* hours first: [{ userId, name, shifts, hours, events }].
|
||||
*/
|
||||
async function hoursServed({ eventWhere = {}, from = null, to = null } = {}, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { checkedInAt: { not: null, ...(from ? { gte: new Date(from) } : {}), ...(to ? { lte: new Date(to) } : {}) }, role: { event: eventWhere } },
|
||||
include: { user: { select: { id: true, name: true } }, role: { include: { session: true, event: { select: { id: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
});
|
||||
const byUser = new Map();
|
||||
for (const s of slots) {
|
||||
const row = byUser.get(s.userId) || { userId: s.userId, name: s.user.name, shifts: 0, hours: 0, events: new Set() };
|
||||
row.shifts += 1;
|
||||
row.hours += shiftHours(shiftOf(s.role, s.role.event));
|
||||
row.events.add(s.role.event.id);
|
||||
byUser.set(s.userId, row);
|
||||
}
|
||||
return [...byUser.values()].map(r => ({ ...r, events: r.events.size })).sort((a, b) => b.hours - a.hours || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const servingUrl = () => `${frontendUrl()}/dashboard/user/serving`;
|
||||
|
||||
/** Send a text to a volunteer the way they like it (WhatsApp, email, or whichever works). Fire-and-forget. */
|
||||
function tell(user, { subject, heading, text }) {
|
||||
if (!user) return Promise.resolve([]);
|
||||
return require('./kids').messageParent(user, { subject, heading, text }).catch(() => []);
|
||||
}
|
||||
|
||||
/** Email the event's notify recipients (or its creator) about a roster change. */
|
||||
async function tellOrganisers(eventId, { subject, text }, db = prisma) {
|
||||
const event = await db.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { createdBy: { select: { email: true } }, notifyRecipients: { select: { email: true } } },
|
||||
});
|
||||
const to = require('./notifications').getEventNotifyEmails(event);
|
||||
if (!to.length) return;
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({ to: to.join(','), subject, ...buildPrivacyEmail({ name: 'team', heading: subject, paragraphs: [text, `Roster: ${frontendUrl()}/dashboard/supervisor/volunteers?event=${eventId}`] }) }).catch(() => {});
|
||||
}
|
||||
|
||||
function placeText(role, event) {
|
||||
return `${role.name} at ${event.title}${role.session ? ` (${sessionsUtil.sessionLabel(role.session, event.sessions)})` : ''}, ${shiftText(shiftOf(role, event))}`;
|
||||
}
|
||||
|
||||
function signedUpMessages({ user, role, event, by = null }) {
|
||||
const text = by
|
||||
? `${by} has put you on the volunteer roster: ${placeText(role, event)}. Thank you for serving! If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`
|
||||
: `Thank you for volunteering: ${placeText(role, event)}. You'll get a reminder the day before. If you can't make it, ask for cover in My account → Serving (${servingUrl()}).`;
|
||||
return tell(user, { subject: `Serving: ${role.name} — ${event.title}`, heading: 'You\'re on the roster', text });
|
||||
}
|
||||
|
||||
/** The cover request goes to the event's other volunteers (not the ones already in this role). */
|
||||
async function sendSwapRequest({ swap, slot, event }, db = prisma) {
|
||||
const others = await db.volunteerSlot.findMany({
|
||||
where: { role: { eventId: event.id }, userId: { not: slot.userId }, NOT: { roleId: slot.roleId } },
|
||||
distinct: ['userId'],
|
||||
include: { user: { select: USER_SELECT } },
|
||||
take: 200,
|
||||
});
|
||||
const inRole = new Set((await db.volunteerSlot.findMany({ where: { roleId: slot.roleId }, select: { userId: true } })).map(s => s.userId));
|
||||
const first = (slot.user.name || '').split(' ')[0] || 'A volunteer';
|
||||
const text = `${first} can't make it and is looking for someone to take over: ${placeText(slot.role, event)}.${swap.note ? ` "${swap.note}"` : ''} If you can, take it over in My account → Serving (${servingUrl()}) — the first to accept gets it.`;
|
||||
for (const o of others) {
|
||||
if (inRole.has(o.userId)) continue;
|
||||
await tell(o.user, { subject: `Cover needed: ${slot.role.name} — ${event.title}`, heading: 'Can you take over?', text });
|
||||
}
|
||||
return others.length;
|
||||
}
|
||||
|
||||
async function sendSwapAccepted({ slot, from, to, event }) {
|
||||
const what = placeText(slot.role, event);
|
||||
await tell(from, { subject: `${to.name} is taking over — ${event.title}`, heading: 'Your place is covered', text: `${to.name} has taken over ${what}. You're off the roster for it — thank you for finding cover.` });
|
||||
await tell(to, { subject: `Serving: ${slot.role.name} — ${event.title}`, heading: 'You\'re on the roster', text: `Thank you for taking over from ${from.name}: ${what}. You'll get a reminder the day before.` });
|
||||
await tellOrganisers(event.id, { subject: `Roster swap: ${slot.role.name} — ${event.title}`, text: `${to.name} has taken over from ${from.name}: ${what}.` });
|
||||
}
|
||||
|
||||
async function sendLeft({ slot, event, byStaff = null }) {
|
||||
const what = placeText(slot.role, event);
|
||||
if (byStaff) {
|
||||
await tell(slot.user, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `${byStaff} has taken you off the roster for ${what}. Thank you for being willing to serve.` });
|
||||
} else {
|
||||
await tellOrganisers(event.id, { subject: `Volunteer withdrew: ${slot.role.name} — ${event.title}`, text: `${slot.user.name} has withdrawn from ${what}.` });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRoleDeleted({ role, event, volunteers }) {
|
||||
for (const u of volunteers) {
|
||||
await tell(u, { subject: `Roster change — ${event.title}`, heading: 'Roster change', text: `The ${role.name} role at ${event.title} is no longer needed, so you're off the roster for it. Thank you for being willing to serve.` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Reminders for shifts starting within the next 24 hours. Returns { sent }. */
|
||||
async function runReminderJob(now = new Date(), db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { remindedAt: null, checkedInAt: null, role: { event: { isActive: true, endDate: { gte: now }, startDate: { lte: new Date(new Date(now).getTime() + 31 * 24 * HOUR) } } } },
|
||||
include: { user: { select: USER_SELECT }, role: { include: { session: true, event: { select: EVENT_SELECT } } } },
|
||||
take: 2000,
|
||||
});
|
||||
let sent = 0;
|
||||
for (const s of slots) {
|
||||
if (s.role.session?.cancelledAt) continue;
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
if (!reminderDue(s, shift, now)) continue;
|
||||
// Claim it first so a second backend process doesn't send it again.
|
||||
const claimed = await db.volunteerSlot.updateMany({ where: { id: s.id, remindedAt: null }, data: { remindedAt: new Date(now) } });
|
||||
if (!claimed.count) continue;
|
||||
const ev = s.role.event;
|
||||
const place = ev.location ? ` at ${ev.location}` : '';
|
||||
await tell(s.user, {
|
||||
subject: `Reminder: serving ${s.role.name} — ${ev.title}`,
|
||||
heading: 'See you soon',
|
||||
text: `A reminder that you're serving: ${placeText(s.role, ev)}${place}. When you arrive, scan the volunteer check-in QR code with your phone. Can't make it? Ask for cover in My account → Serving (${servingUrl()}).`,
|
||||
});
|
||||
sent += 1;
|
||||
}
|
||||
return { sent };
|
||||
}
|
||||
|
||||
// ─── Privacy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** For "Download my data". */
|
||||
async function exportVolunteering(userId, db = prisma) {
|
||||
const slots = await db.volunteerSlot.findMany({
|
||||
where: { userId },
|
||||
include: { role: { include: { session: true, event: { select: { title: true, startDate: true, endDate: true, sessions: true } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return slots.map(s => {
|
||||
const shift = shiftOf(s.role, s.role.event);
|
||||
return { event: s.role.event.title, role: s.role.name, from: shift.startsAt, to: shift.endsAt, signedUp: s.createdAt, assigned: !!s.assignedById, checkedIn: s.checkedInAt };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For anonymiseUser: shifts still to come are given up (open places, free tickets released);
|
||||
* past ones stay for the hours-served totals, under the anonymised account. Swap notes go.
|
||||
*/
|
||||
async function forgetVolunteer(tx, userId, now = new Date()) {
|
||||
const slots = await tx.volunteerSlot.findMany({ where: { userId, checkedInAt: null }, include: { role: { include: { session: true, event: { select: { startDate: true, endDate: true, sessions: true } } } } } });
|
||||
let removed = 0;
|
||||
for (const s of slots) {
|
||||
if (shiftOf(s.role, s.role.event).endsAt <= new Date(now)) continue;
|
||||
await tx.volunteerSwap.updateMany({ where: { slotId: s.id, status: 'open' }, data: { status: 'cancelled', resolvedAt: new Date(now) } });
|
||||
await releasePlace(tx, s);
|
||||
await tx.volunteerSlot.delete({ where: { id: s.id } });
|
||||
removed += 1;
|
||||
}
|
||||
await tx.volunteerSwap.updateMany({ where: { OR: [{ fromUserId: userId }, { toUserId: userId }], note: { not: null } }, data: { note: null } });
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QR_PREFIX, MAX_NEEDED, volunteerError,
|
||||
shiftOf, overlaps, shiftHours, fillOf, checkInOpen, reminderDue, kidsOccasion, reservedOptionIds, publicOptions, shiftText,
|
||||
loadEvent, roleView, roster, createRole, updateRole, deleteRole, getSettings, saveSettings, checkInQr,
|
||||
givePlace, releasePlace, signUp, leave, requestSwap, cancelSwap, acceptSwap, openSwaps,
|
||||
checkInWithQr, checkInManual, rosteredForRooms, mySlots, eventsWithRoles, hoursServed,
|
||||
signedUpMessages, sendSwapRequest, sendSwapAccepted, sendLeft, sendRoleDeleted, runReminderJob,
|
||||
exportVolunteering, forgetVolunteer,
|
||||
};
|
||||
@@ -0,0 +1,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));
|
||||
});
|
||||
+39
-11
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.45.0** (and Hub 1.1.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.46.1** (and Hub 1.1.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,9 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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.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 |
|
||||
@@ -40,13 +43,10 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
## Next up
|
||||
|
||||
1. **Roll out the update requests:** deploy **Hub 1.1.0**, then set "Latest Cross Events release"
|
||||
to `1.42.0` in Hub → Settings, then update the sites to 1.42.0 (the last update done without a
|
||||
request). A 1.42.0 site talking to an older Hub shows "The Hub doesn't take update requests yet".
|
||||
2. **Try `scripts/install.sh` on a test server.** The production Hub key is built in since
|
||||
1.42.1, so a fresh install needs no `HUB_PUBLIC_KEY`; existing installs can drop it from `.env`.
|
||||
3. **1.46.0 — Volunteer rosters** (§5), per campus. Wallet passes (§7) are removed from scope
|
||||
(2026-10-28), so 1.47.0 is event series & cloning and 1.48.0 outbound webhooks.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.47.0 — Event series & cloning** (§6), then **1.48.0 — outbound webhooks** (§8).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -99,9 +99,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
|
||||
|
||||
@@ -253,6 +253,30 @@ encrypted settings unreadable or break existing installs.
|
||||
- e2e: `kids.spec.ts` on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk
|
||||
checks in, parent sees the code, wrong code then the right one, room alert).
|
||||
|
||||
## Where volunteer rosters live (1.46.0)
|
||||
|
||||
- `backend/src/utils/volunteers.js` — everything: shifts (`shiftOf`: the role's own times, else its
|
||||
session's, else the event's), overlap and check-in-window rules, roles, sign-up / leave / swaps,
|
||||
the free volunteer place (`givePlace` / `releasePlace`, `VolunteerSlot.registrationId`), QR and
|
||||
manual check-in (kids-room roles start a `KidsRoomDuty` with `fromRoster`), `rosteredForRooms`
|
||||
(used by the kids board), messages, the `volunteers` reminder job, hours served, and the privacy
|
||||
parts (`exportVolunteering`, `forgetVolunteer`). Pure parts tested in `tests/volunteers.test.js`.
|
||||
- `reservedOptionIds` / `publicOptions` there: the kids and volunteer tickets are left out of public
|
||||
event pages and refused on public registration (`eventController`, `registrationController`).
|
||||
**A new staff-only ticket type should be added to `reservedOptionIds`.**
|
||||
- `controllers/volunteerController.js` + `routes/volunteerRoutes.js` (`/api/volunteers`); campus
|
||||
resolvers `servingRole` and `volunteerSlot`; sign-ups, new roles and assigning are in `readOnly.js`.
|
||||
- Swaps: no approval — accepting moves the slot at once (claimed with an `updateMany` on
|
||||
`status: 'open'` so two people can't both take it); `tellOrganisers` emails
|
||||
`getEventNotifyEmails(event)` (notify recipients, else the creator), not the registrations inbox.
|
||||
- The check-in QR holds `SERVE-<Event.volunteerCheckInToken>`; the page encodes it as a URL
|
||||
(`/serve/check-in?code=…`) so phone cameras open it directly.
|
||||
- Frontend: `lib/volunteers.ts`, `components/volunteers/{RosterSheet,CheckInQr}.tsx`, pages
|
||||
`dashboard/supervisor/volunteers` (roles, QR, ticket, hours), `dashboard/staff/volunteers` (the day),
|
||||
`dashboard/user/serving` (`?event=<id>` opens one event — the shareable sign-up link), `serve/check-in`.
|
||||
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
|
||||
sign-up, cover request, staff take over, QR check-in, roster).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -274,3 +298,7 @@ encrypted settings unreadable or break existing installs.
|
||||
- Kids check-in: the offline desk is tested at the API level (sync, idempotency, code
|
||||
reservation), not in the browser. A family's two parents share one family; linking a second
|
||||
parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.
|
||||
- Volunteer rosters: if a volunteer edits their free volunteer registration on the edit page, the
|
||||
volunteer ticket isn't among the public options, so saving drops it (the backend only refuses
|
||||
adding or increasing it). Rare; the place is still in the roster, and a supervisor can give a
|
||||
ticket at the door. Reminders are a single "day before" one (no per-event timing yet).
|
||||
|
||||
+6
-3
@@ -32,7 +32,9 @@ per campus, public filter and campus pages, home campus, filters, campus broadca
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). **Next: 1.46.0 Volunteer rosters.**
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
**Next: 1.47.0 Event series and cloning.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -443,7 +445,7 @@ Parents manage their family's children in My account and see pickup codes on the
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
Plan who serves where, for an event or each session. (Shipped in 1.46.0 — decisions below.)
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
@@ -950,7 +952,7 @@ 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 |
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 – 1.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 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 |
|
||||
@@ -1005,6 +1007,7 @@ feature.
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | 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
|
||||
|
||||
+1
-1
@@ -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; kids: string };
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string };
|
||||
campuses: Record<"north" | "south", { id: string; slug: string }>;
|
||||
northTicketQr: string;
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
|
||||
@@ -88,6 +88,14 @@ async function seed(url) {
|
||||
price: 0, isHidden: true, createdById: users.supervisor.id,
|
||||
},
|
||||
});
|
||||
// Volunteer rosters (volunteers.spec.ts): on today, hidden.
|
||||
const serving = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Serving Sunday', description: 'Volunteer rosters for the tests.', startDate: new Date(Date.now() - 60 * 60 * 1000), endDate: new Date(Date.now() + 6 * 60 * 60 * 1000),
|
||||
price: 0, isHidden: true, createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
const paid = await prisma.event.create({
|
||||
data: {
|
||||
campusId: south.id,
|
||||
@@ -181,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, kids: kids.id },
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id },
|
||||
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
|
||||
northTicketQr: northTicket.qrCode,
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -109,6 +109,11 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
kids_wrong_code: "Wrong pickup code entered",
|
||||
kids_custody_notes_changed: "Child's custody notes changed",
|
||||
kids_offline_sync: "Offline kids desk synced",
|
||||
volunteer_settings_updated: "Volunteer ticket changed",
|
||||
volunteer_roles_changed: "Serving role added, changed or deleted",
|
||||
volunteer_assigned: "Volunteer put on a roster",
|
||||
volunteer_removed: "Volunteer taken off a roster",
|
||||
volunteer_checked_in_manual: "Volunteer checked in by hand",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
|
||||
Baby,
|
||||
Baby, HandHeart,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby } from "lucide-react";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
@@ -17,6 +17,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/staff/volunteers", label: "Volunteers", description: "Who is serving today, check-ins and the check-in QR code", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import type { RosterEvent } from "@/lib/volunteers";
|
||||
|
||||
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
|
||||
export default function StaffVolunteersPage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<RosterEvent[] | null>(null);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const now = Date.now();
|
||||
const current = list.find(e => new Date(e.endDate).getTime() >= now);
|
||||
if (current) setEventId(current.id);
|
||||
})
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const event = events?.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteers</h1>
|
||||
<p className="text-sm text-gray-500">Who's serving today, and checking them in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
|
||||
{events && events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 items-end print:hidden">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{!eventId && <option value="">Choose an event…</option>}
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
<div className="mt-4">
|
||||
{eventId && (showQr
|
||||
? <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />
|
||||
: <RosterSheet token={token} eventId={eventId} canEdit={false} onMessage={setMsg} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
|
||||
Baby,
|
||||
Baby, HandHeart,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
|
||||
|
||||
type Settings = {
|
||||
event: { id: string; title: string; volunteerOptionId: string | null };
|
||||
options: { id: string; name: string; price: number }[];
|
||||
kidsRooms: { id: string; name: string }[];
|
||||
};
|
||||
type Tab = "roster" | "qr" | "ticket" | "hours";
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "roster", label: "Roster" },
|
||||
{ id: "qr", label: "Check-in QR" },
|
||||
{ id: "ticket", label: "Volunteer ticket" },
|
||||
{ id: "hours", label: "Hours served" },
|
||||
];
|
||||
|
||||
/** Supervisor → Volunteer rosters: serving roles per event or session, people, check-in QR, volunteer ticket, hours. */
|
||||
export default function VolunteerRostersPage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<RosterEvent[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("roster");
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [hours, setHours] = useState<HoursRow[] | null>(null);
|
||||
const [hoursAll, setHoursAll] = useState(true);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const wanted = new URLSearchParams(window.location.search).get("event");
|
||||
if (wanted && list.some(e => e.id === wanted)) setEventId(wanted);
|
||||
})
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (!token || !eventId) { setSettings(null); return; }
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
|
||||
}
|
||||
}, [token, eventId, setMsg]);
|
||||
useEffect(() => { loadSettings(); }, [loadSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || tab !== "hours") return;
|
||||
setHours(null);
|
||||
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
|
||||
.then(setHours)
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
|
||||
}, [token, tab, hoursAll, eventId, setMsg]);
|
||||
|
||||
const saveTicket = async (optionId: string | null, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
|
||||
setMsg({ type: "ok", text: ok });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const event = events.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Volunteer rosters</h1>
|
||||
<p className="text-sm text-gray-500">Who serves where, for an event or each session</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md print:hidden">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-4 border-b print:hidden" role="tablist">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)} className={`px-3 py-2 text-sm -mb-px border-b-2 ${tab === t.id ? "border-brand-600 text-brand-700 font-medium" : "border-transparent text-gray-500"}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{tab !== "hours" && !eventId && <p className="text-sm text-gray-500">Choose an event.</p>}
|
||||
{tab === "roster" && eventId && (
|
||||
<p className="text-xs text-gray-500 mb-3 print:hidden">
|
||||
Sign-up link to share: <span className="font-mono select-all break-all" data-testid="signup-link">{`${typeof window !== "undefined" ? window.location.origin : ""}/dashboard/user/serving?event=${eventId}`}</span>
|
||||
</p>
|
||||
)}
|
||||
{tab === "roster" && eventId && <RosterSheet token={token} eventId={eventId} canEdit kidsRooms={settings?.kidsRooms || []} onMessage={setMsg} />}
|
||||
{tab === "qr" && eventId && <CheckInQr token={token} eventId={eventId} title={event?.title || ""} />}
|
||||
{tab === "ticket" && eventId && settings && (
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
|
||||
<h2 className="font-semibold">Free volunteer ticket</h2>
|
||||
<p className="text-sm text-gray-600">Off unless you choose a ticket. When it's on, everyone on this event's roster gets a free place on it (shown in their My Events), unless they already have a ticket. It's never offered to the public, and it's given back when they leave the roster.</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select aria-label="Volunteer ticket" className="border rounded-lg px-3 py-2 text-sm" value={settings.event.volunteerOptionId || ""} disabled={busy}
|
||||
onChange={e => saveTicket(e.target.value || null, e.target.value ? "Volunteers get a free place on this ticket." : "No free volunteer ticket.")}>
|
||||
<option value="">Off — no free ticket</option>
|
||||
{settings.options.map(o => <option key={o.id} value={o.id}>{o.name}{o.price ? ` (R${o.price})` : " (free)"}</option>)}
|
||||
</select>
|
||||
{!settings.event.volunteerOptionId && <button disabled={busy} onClick={() => saveTicket("new", "Added a free \"Volunteer\" ticket — volunteers get a place on it.")} className="px-3 py-2 rounded-lg border text-sm">Add a free "Volunteer" ticket</button>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Places already given stay when you switch it off.</p>
|
||||
</section>
|
||||
)}
|
||||
{tab === "hours" && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="inline-flex items-center gap-1"><input type="radio" checked={hoursAll} onChange={() => setHoursAll(true)} /> All events</label>
|
||||
<label className="inline-flex items-center gap-1"><input type="radio" checked={!hoursAll} disabled={!eventId} onChange={() => setHoursAll(false)} /> This event only</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Shifts people checked in for, counted at the shift's length.</p>
|
||||
{!hours && <div className="text-sm text-gray-500">Loading…</div>}
|
||||
{hours && hours.length === 0 && <p className="text-sm text-gray-500">No checked-in shifts yet.</p>}
|
||||
{hours && hours.length > 0 && (
|
||||
<table className="w-full text-sm border rounded-xl overflow-hidden bg-white">
|
||||
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">Volunteer</th><th className="p-2">Shifts</th><th className="p-2">Events</th><th className="p-2">Hours</th></tr></thead>
|
||||
<tbody>{hours.map(h => <tr key={h.userId} className="border-t"><td className="p-2">{h.name}</td><td className="p-2">{h.shifts}</td><td className="p-2">{h.events}</td><td className="p-2 font-medium">{hoursText(h.hours)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
|
||||
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
|
||||
|
||||
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50";
|
||||
|
||||
/** My account → Serving: my shifts, cover requests I could take, and events looking for volunteers. */
|
||||
export default function ServingPage() {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<Mine | null>(null);
|
||||
const [msg, setMsg] = useDismissingState<Msg>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [openEvent, setOpenEvent] = useState<string>("");
|
||||
const [roster, setRoster] = useState<Roster | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
|
||||
}
|
||||
}, [token, setMsg]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// ?event=<id>: the sign-up link a supervisor shares (works for hidden events too).
|
||||
useEffect(() => {
|
||||
const wanted = new URLSearchParams(window.location.search).get("event");
|
||||
if (wanted) setOpenEvent(wanted);
|
||||
}, []);
|
||||
|
||||
const loadRoster = useCallback(async (id: string) => {
|
||||
if (!token || !id) { setRoster(null); return; }
|
||||
try {
|
||||
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
|
||||
}
|
||||
}, [token, setMsg]);
|
||||
useEffect(() => { loadRoster(openEvent); }, [openEvent, loadRoster]);
|
||||
|
||||
const call = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
setMsg({ type: "ok", text: ok });
|
||||
await Promise.all([load(), openEvent ? loadRoster(openEvent) : null]);
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const askCover = (s: MySlot) => {
|
||||
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
|
||||
if (note === null) return;
|
||||
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
|
||||
};
|
||||
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
|
||||
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
|
||||
|
||||
const upcoming = (data?.slots || []).filter(s => !s.past);
|
||||
const past = (data?.slots || []).filter(s => s.past);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><HandHeart className="w-5 h-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Serving</h1>
|
||||
<p className="text-sm text-gray-500">Your volunteer shifts, and events looking for people to serve</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
{!data && <div className="text-sm text-gray-500">Loading…</div>}
|
||||
|
||||
{data && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">My shifts</h2>
|
||||
{upcoming.length === 0 && <p className="text-sm text-gray-500">You're not on any roster yet. Sign up for a role below.</p>}
|
||||
{upcoming.map(s => (
|
||||
<div key={s.slotId} className="border rounded-xl p-4 bg-white shadow-sm" data-testid="my-shift">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">{s.role.name} <span className="font-normal text-gray-500">· {s.event.title}</span></div>
|
||||
<div className="text-sm text-gray-600">{s.role.sessionLabel ? `${s.role.sessionLabel} · ` : ""}{s.shift.text}{s.event.location ? ` · ${s.event.location}` : ""}</div>
|
||||
{s.role.kidsRoom && <div className="text-xs text-gray-500">Kids room: {s.role.kidsRoom}</div>}
|
||||
{s.role.description && <div className="text-xs text-gray-500 mt-1">{s.role.description}</div>}
|
||||
{s.hasTicket && <div className="text-xs text-gray-500 mt-1">Your free volunteer ticket is in My Events.</div>}
|
||||
</div>
|
||||
{s.checkedInAt
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> Checked in</span>
|
||||
: s.swapId && <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">Cover requested</span>}
|
||||
</div>
|
||||
{s.canCheckIn && <p className="mt-2 text-sm text-brand-700 flex items-center gap-1"><QrCode className="w-4 h-4" /> When you arrive, scan the volunteer check-in QR code with your phone's camera.</p>}
|
||||
{!s.checkedInAt && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{s.swapId
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
|
||||
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can't make it</button>}
|
||||
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{past.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500">Last week ({past.length})</summary>
|
||||
<ul className="mt-1 space-y-0.5 text-gray-600">
|
||||
{past.map(s => <li key={s.slotId}>{s.role.name} · {s.event.title} · {s.shift.text}{s.checkedInAt ? " · served" : ""}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data && data.cover.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Can you take over?</h2>
|
||||
{data.cover.map(c => (
|
||||
<div key={c.swapId} className="border border-amber-300 rounded-xl p-4 bg-amber-50 flex flex-wrap items-center justify-between gap-3" data-testid="cover-request">
|
||||
<div>
|
||||
<div className="font-semibold">{c.role.name} <span className="font-normal text-gray-600">· {c.event.title}</span></div>
|
||||
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
|
||||
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can't make it{c.note ? `: “${c.note}”` : ""}</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Serve at an event</h2>
|
||||
{data.events.length === 0 && !openEvent && <p className="text-sm text-gray-500">No events are looking for volunteers right now.</p>}
|
||||
{(data.events.length > 0 || openEvent) && (
|
||||
<label className="text-sm block max-w-md">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={openEvent} onChange={e => setOpenEvent(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{roster && !data.events.some(e => e.id === roster.event.id) && <option value={roster.event.id}>{roster.event.title} · {formatDate(new Date(roster.event.startDate))}</option>}
|
||||
{data.events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))} ({e.roles} role{e.roles === 1 ? "" : "s"})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{roster && roster.event.id === openEvent && (
|
||||
<div className="space-y-2">
|
||||
{roster.roles.filter(r => !r.past).map(r => (
|
||||
<div key={r.id} className="border rounded-xl p-3 bg-white flex flex-wrap items-center justify-between gap-3" data-testid="open-role">
|
||||
<div>
|
||||
<div className="font-medium">{r.name}{r.kidsRoom ? <span className="text-xs text-gray-500"> · kids room {r.kidsRoom.name}</span> : null}</div>
|
||||
<div className="text-sm text-gray-600">{r.sessionLabel ? `${r.sessionLabel} · ` : ""}{r.shift.text}</div>
|
||||
{r.description && <div className="text-xs text-gray-500">{r.description}</div>}
|
||||
<div className="text-xs text-gray-500">{r.filled} of {r.needed} signed up</div>
|
||||
</div>
|
||||
{r.mine
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You're serving</span>
|
||||
: r.coverNeeded.length > 0
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
: r.full
|
||||
? <span className="text-sm text-gray-500">Full</span>
|
||||
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
</div>
|
||||
))}
|
||||
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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's details (when booking for them or passing on a ticket) if you'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'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'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'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: [
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { CheckCircle2, HandHeart } from "lucide-react";
|
||||
|
||||
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
|
||||
|
||||
/** Where the volunteer check-in QR code leads: checks the signed-in volunteer in for today's shifts. */
|
||||
function CheckIn() {
|
||||
const code = useSearchParams().get("code") || "";
|
||||
const { user, token, loading } = useAuth();
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
const [error, setError] = useState<string | null>(code ? null : "This check-in link is incomplete — scan the QR code again.");
|
||||
const sent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code || loading || !token || sent.current) return;
|
||||
sent.current = true;
|
||||
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
|
||||
.then(setResult)
|
||||
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
|
||||
}, [code, loading, token]);
|
||||
|
||||
if (error) return <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm" role="alert">{error} <Link href="/dashboard/user/serving" className="underline">My shifts</Link></div>;
|
||||
if (loading) return <div className="text-sm text-gray-600">Loading…</div>;
|
||||
if (!user) {
|
||||
const back = `/serve/check-in?code=${encodeURIComponent(code)}`;
|
||||
return (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<p className="text-sm text-gray-700">Log in to check in for your volunteer shift.</p>
|
||||
<Link href={`/login?redirect=${encodeURIComponent(back)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Log in</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result) return <div className="text-sm text-gray-600">Checking you in…</div>;
|
||||
return (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3" data-testid="volunteer-checked-in">
|
||||
<div className="flex items-center gap-2 text-green-700 font-semibold"><CheckCircle2 className="w-6 h-6" /> Thank you for serving, {user.name?.split(" ")[0]}!</div>
|
||||
<p className="text-sm text-gray-700">You're checked in at <strong>{result.event.title}</strong>:</p>
|
||||
<ul className="text-sm text-gray-800 list-disc pl-5">
|
||||
{result.checkedIn.map(c => <li key={c.role}>{c.role}{c.kidsRoom ? ` — you're on duty in ${c.kidsRoom}` : ""}</li>)}
|
||||
{result.already.map(r => <li key={r}>{r} (already checked in)</li>)}
|
||||
</ul>
|
||||
<Link href="/dashboard/user/serving" className="text-sm text-brand-600 hover:underline">My shifts</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VolunteerCheckInPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<HandHeart className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Volunteer check-in</h1>
|
||||
</div>
|
||||
<Suspense fallback={<div className="text-sm text-gray-600">Loading…</div>}>
|
||||
<CheckIn />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function RoomBoard({ board, token, roomId, onChanged, onMessage }: Props)
|
||||
<RoomStatusBadges room={r} />
|
||||
</div>
|
||||
{r.leaderNames.length > 0 && <div className="text-xs text-gray-500 mb-2 flex items-center gap-1"><Users className="w-3.5 h-3.5" /> On duty: {r.leaderNames.join(", ")}</div>}
|
||||
{(r.rostered || []).some(v => !v.checkedIn) && <div className="text-xs text-gray-500 mb-2">Rostered, not checked in yet: {r.rostered.filter(v => !v.checkedIn).map(v => v.name).join(", ")}</div>}
|
||||
{inRoom.length === 0 && <p className="text-sm text-gray-500">No children in this room.</p>}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{inRoom.map(c => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, Baby, type LucideIcon } from "lucide-react";
|
||||
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, Baby, HandHeart, type LucideIcon } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
|
||||
import { UserAvatar } from "@/components/shared/UserAvatar";
|
||||
@@ -13,6 +13,7 @@ const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[
|
||||
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/kids", label: "Kids", icon: Baby, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/serving", label: "Serving", icon: HandHeart, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/privacy", label: "Privacy", icon: Lock, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
|
||||
|
||||
@@ -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's camera and log in — you'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"}'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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ 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. */
|
||||
@@ -181,6 +182,21 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
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",
|
||||
|
||||
@@ -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 "Add a role": 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's (or event'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'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't full, under My account → Serving. You can also put someone on a role with the person button — even when it's full. They'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't make it asks for cover. The event's other volunteers are told, and the first to accept takes the place straight away — nobody needs to approve it. The event'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 "Check-in QR" and put it up where volunteers arrive. They scan it with their phone'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). "Print" 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 "Volunteer" ticket) and everyone on the roster gets a free place on it, unless they already have a ticket. It'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'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 "Show the check-in QR code" to put it on a screen. Someone without a phone? Tap "Check in" 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'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 "Serve at an event" and sign up for a role that has space. You'll get a reminder the day before. When you arrive, scan the volunteer check-in QR code with your phone's camera.</p>
|
||||
<GuideItem icon={Repeat} title="Can't make it?" tone="amber">
|
||||
Tap "Can't make it". The other volunteers at the event are asked to take over, and you stay on the roster until someone does — then you'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>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -11,7 +11,10 @@ export type KidsFamily = { id: string; name: string; parents: KidsParent[]; chil
|
||||
|
||||
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[]; children: number; leaders: number; leadersNeeded: number; full: boolean; overRatio: boolean;
|
||||
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;
|
||||
|
||||
@@ -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`;
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user