Compare commits

...
Author SHA1 Message Date
joshua 42a369278e Merge branch 'feat/kids-check-in'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-28 01:01:09 +02:00
joshuaandClaude Opus 5.5 e249d43c79 Kids check-in: docs, changelog (1.45.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:08 +02:00
joshuaandClaude Opus 5.5 00b465b97d Kids check-in: desk, room screen, setup and parent pages, help, e2e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:08 +02:00
joshuaandClaude Opus 5.5 794396ca9c Kids check-in: families, pickup codes, rooms, check-in/out, offline sync (backend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:46:41 +02:00
joshuaandClaude Opus 5.5 09ef2c1df1 Roadmap: kids check-in fully digital, wallet passes removed from scope
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:29:10 +02:00
joshua 36c3b117ca Merge branch 'feat/campus-scoped-staff'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 22:59:00 +02:00
joshuaandClaude Opus 5.5 cad075bb3f Campus-scoped staff: help, docs, changelog (1.44.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:52:11 +02:00
joshuaandClaude Opus 5.5 159728d269 Campus-scoped staff: frontend and e2e
Admin -> Users 'Limit to campuses'; staff campus pickers, reports, dashboards
and audience picker follow the scope (useMyCampuses); scoped people pick one
of their campuses for new events, and church-wide / other campuses' events
open read-only; 'You work with ...' note; warning before turning campuses off
while staff are limited. e2e: scanner refusal naming the campus, scoped
events list and read-only editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:50:03 +02:00
joshuaandClaude Opus 5.5 c570fc7ffb Campus-scoped staff: admins set limits; profile returns the scope
PUT /api/users/:id takes scopedCampusIds (supervisors/staff only, audited as
user_campus_scope_changed); GET returns scopedCampuses; the profile returns
campusScope (null when unscoped or campuses are off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:40:32 +02:00
joshuaandClaude Opus 5.5 00fd26f3a5 Campus-scoped staff: every staff/supervisor route checked
Events, payments, registrations, scan locks and tickets marked; list
controllers filtered (registrations, payments, tickets, scans, stats,
coupons, scheduled messages, form responses, funnel). Owner-or-staff routes
check staff against the record's event (ownerOk). Scanner names the campus
of another campus's ticket. Route test passes; unit tests for the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:37:42 +02:00
joshuaandClaude Opus 5.5 344f260011 Campus-scoped staff: schema, campusScope helper, route test, first 79 routes
UserCampusScope relation and audit action; scopeEvent/scopeList/unscopedOnly/
campusIndependent/scopeBroadcast markers; tests/campusScopeRoutes.test.js
(still failing: events, payments, registrations, scan locks, tickets left).
Can't delete a campus that has scoped staff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:28:30 +02:00
joshuaandClaude Opus 5.5 d2d9282a2c Roadmap: 1.44.0 campus-scoped staff decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:17:32 +02:00
joshua f36e84fd90 Merge branch 'feat/campuses'
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
2026-10-27 16:56:07 +02:00
joshuaandClaude Opus 5.5 a6909ce506 Campuses frontend, docs and e2e (1.43.0)
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
Site settings -> Campuses tab; campus picker in the event editor; campus
filter and badges on the events list, public events page (remembered,
starts on home campus) and reports/dashboards; /campus/<slug> pages and
sitemap; home campus on the profile; "Everyone at a campus" in email and
WhatsApp broadcasts. Help, FEATURES, SETTINGS, API docs, protocol doc
(campusCount), handover and roadmap updated; campuses seeded for e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:45:43 +02:00
joshuaandClaude Opus 5.5 955f8d9831 Campuses backend: model, admin/public API, events per campus, filters
- Campus model, Event.campusId (null = all campuses), User.homeCampusId,
  per-campus summary recipients; campuses_enabled setting (off by default)
- /api/campuses: public list and landing page, admin CRUD/order/archive,
  "everyone at campus X" audience (home campus + optional 12-month registrants)
- Events: campus on create/update (audited when moved), ?campus= on the
  public list (with church-wide events) and staff list; campus included
- Home campus on the profile; cleared when anonymising
- Overview stats ?campus= filter; campus summary recipients; campus on
  ticket PDFs; heartbeat reports the real campus setting
- Migration also adds hub_update_requested, missing since 1.42.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:23:33 +02:00
joshua 2727fc6554 Merge branch 'docs/multi-campus-plan' 2026-10-27 16:09:06 +02:00
122 changed files with 6669 additions and 313 deletions
+92
View File
@@ -7,6 +7,98 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.45.0] - 2026-10-28
### Added
- **Kids check-in** — a secure, fully digital check-in and pickup for children's ministry, run
from a staff desk. No label printers needed.
- **Families are kept between events** (weekly kids church): parents add their children under
My account → **Kids** (date of birth, allergies, medical and custody notes) and list
**authorised adults** who may collect them (no account needed). Staff can find a family by
name or phone, scan the parent's family QR code, or add a new family on the spot.
- **Pickup codes:** checking in gives the family a 4-character code for the day (or session),
shared by the children checked in together. It shows on the parent's phone with a QR code and
is sent by WhatsApp or email (and, if chosen, to an authorised adult). Anyone with the code can
collect.
- **"Write on the sticker" panel:** first name, room, code and a short ALLERGY/MEDICAL alert in
large print, for volunteers to copy onto a blank sticker.
- **Rooms** with an age range, capacity and ratio (children per leader). The desk suggests the
room from the child's age; the room screen shows live cards with allergies, medical and custody
notes, "I'm on duty", and warns when a room is full or over the ratio.
- **Check-out** by typing or scanning the code. A wrong code is refused, shown as an alert on the
room screens and recorded in the audit log; checking out without the code needs a reason.
"Call parent" asks the parents to come to the room.
- **Offline desk:** download the families and 20 pickup codes for the device beforehand, keep
checking in without a connection, and sync afterwards.
- Children show on the **attendance register** and reports: each gets a free place on the event's
kids ticket the first time, and every check-in counts as attendance.
- Set up per event under Supervisor → **Kids check-in setup**; works per campus.
- **Privacy:** allergies, medical and custody notes are encrypted and never put in messages;
opening the desk or room screens is written to the access log; families are in "Download my
data" and anonymising; a new retention rule deletes a child's profile after **6 months**
without a check-in (on by default); a readiness check for it.
- Rooms in Rooms & groups take an age range and a ratio (`minAge`, `maxAge`, `ratio`).
### Changed
- Apple and Google Wallet tickets are removed from the 2.0 plan.
## [1.44.0] - 2026-10-27
### Added
- **Staff for some campuses** (Admin → Users → "Limit to campuses", while campuses are on). A
supervisor or staff member limited to some campuses sees only those campuses' events plus
events for all campuses — in every list, report, dashboard, the scanner and offline scanning.
- **Events for all campuses:** they can scan, register people and take payments at the door, but
not change them, cash them up or refund them (the event editor opens read-only).
- **New events** go at one of their campuses; they can't move events to another campus.
- **Admins and supervisors for every campus only:** the site banner, payment reconciliation and
open online checkouts, and donations not linked to an event.
- **Messages:** broadcasts only to people at their campuses or attendees of their events (no
typed-in addresses). **Coupons:** only for their campuses' events.
- Another campus's records answer "not found"; the **scanner says which campus** a ticket is
for. Staff still see their own registrations for any campus's event.
- Their dashboard says which campuses they work with. Admins are never limited, and limits are
ignored while campuses are off (the Campuses tab warns first). Changing someone's limits is in
the audit log; a campus with staff limited to it can't be deleted.
- Every staff and supervisor route declares how it handles campus limits, and a test fails on
any that doesn't (`tests/campusScopeRoutes.test.js`).
## [1.43.0] - 2026-10-27
### Added
- **Campuses** (Site settings → Campuses, off by default) for a church with more than one campus
in one install. Churches with one campus see no campus fields anywhere.
- Admins turn campuses on, then add, order, archive and delete campuses: name, web address,
address, contact, colour, logo, default event location, and staff who get that campus's daily
summary. A campus with events can't be deleted, only archived. Turning campuses off hides
everything campus-related and deletes nothing.
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills
in its default location; moving an event to another campus is in the audit log. The Events
list in the dashboard shows each event's campus and has a campus filter.
- **Public site:** a campus filter on the events page, remembered in the visitor's browser and
starting on their home campus; a campus shows its own events plus church-wide ones. The campus
shows on event cards, the event page and ticket PDFs. **Campus pages** at `/campus/<slug>`
(also in the sitemap).
- **Home campus** on the profile page.
- **Reports and dashboards:** a campus filter on Reports (all reports and the funnel) and on the
admin and supervisor dashboards' past-month tiles, with a church-wide total.
- **Broadcasts:** "Everyone at a campus" in Email and WhatsApp broadcasts — people whose home
campus it is, plus (ticked) people who registered for its events in the last 12 months. News
consent still applies.
- **Daily summary:** campus recipients also get the summary of their campus's events.
- The Hub heartbeat reports whether campuses are on and how many there are (`campusCount`).
- Campus-scoped staff (supervisors and staff limited to some campuses) follow in 1.44.0.
- The privacy notice lists the new `hope_events_campus` browser-storage item.
### Fixed
- "Request update" (1.42.0) wasn't written to the audit log: the `hub_update_requested` action was
missing from the database. The request itself always worked.
## [1.42.1] - 2026-10-27
### Changed
+84 -9
View File
@@ -29,12 +29,14 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security)
17. [Privacy (POPIA)](#privacy-popia)
18. [Help system](#help-system)
19. [Background jobs](#background-jobs)
20. [Operations](#operations)
21. [Page map](#page-map)
16. [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)
---
@@ -393,6 +395,8 @@ logout controls. Disconnected sessions are recovered automatically.
with event and section filters.
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
and print an event's tickets.
- **Kids check-in** (`/dashboard/staff/kids`) and the **room screen**
(`/dashboard/staff/kids/rooms`) — see [Kids check-in](#kids-check-in).
---
@@ -511,6 +515,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Tab | Contains |
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
@@ -523,6 +528,75 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Multi-campus
For a church with several campuses in one install (Site settings → Campuses, off by
default — single-campus churches never see campus fields).
- **Events** belong to one campus or to **All campuses** (church-wide). Choosing a campus fills in
its default location. Moving an event to another campus is in the audit log.
- **Public site:** a campus filter on the events page, remembered per visitor and starting on their
home campus; a campus shows its own events plus church-wide ones. The campus shows on event cards,
the event page and ticket PDFs. Each campus has a page at `/campus/<slug>` (also in the sitemap).
- **People** choose a home campus on their profile.
- **Reports and dashboards:** a campus filter on Reports (every report and the funnel) and on the
admin/supervisor dashboards' month-over-month tiles, with a church-wide total.
- **Messages:** "Everyone at a campus" in Email and WhatsApp broadcasts — home campus, plus (ticked)
people who registered for its events in the last 12 months. News consent still applies.
- **Daily summary:** campus recipients get the summary of their campus's events.
- **Turning it off** hides everything campus-related; nothing is deleted. A campus with events can't
be deleted, only archived.
- **Staff for some campuses** (Admin → Users → "Limit to campuses"): a supervisor or staff member
sees only their campuses' events plus events for all campuses — every list, report, dashboard
and scanner follows it. For church-wide events they can scan, register people and take payments
at the door, but not change, cash up or refund them. The site banner, payment reconciliation and
donations not linked to an event are for admins and unscoped supervisors; broadcasts only reach
people at their campuses or their events' attendees; coupons only for their campuses' events.
Another campus's records answer "not found"; the scanner says which campus a ticket is for.
Admins are never limited; limits are ignored while campuses are off.
---
## Kids check-in
Secure check-in and pickup for children's ministry, run from a staff desk and fully digital — no
label printers.
- **Setup** (Supervisor → Kids check-in setup, `/dashboard/supervisor/kids`): per event, a
**kids ticket** (a main ticket covering the whole event — "Add a free Kids check-in ticket"
makes one) and **rooms** (an allocation set; each room has an age range, capacity and children
per leader), then switch it on. Weekly kids church = one event with a session per Sunday.
- **Families** are kept between events: parents add their children under My account → **Kids**
(`/dashboard/user/kids`) — date of birth, allergies, medical and custody notes — and their
**authorised adults** (name, phone, relationship; no account needed). The page shows a family QR
code for the desk.
- **Desk** (`/dashboard/staff/kids`): find the family (name, phone or family QR) or add one, tick
the children (room suggested from the age: the narrowest range that isn't full), check in. The
family gets a **4-character pickup code** for the day or session (no 0/O/1/I), sent to the parents
by WhatsApp/email and shown on their Kids page with a QR code; it can also go to an authorised
adult. A **"write on the sticker"** panel shows first name, room, code and ALLERGY/MEDICAL in
large print to copy onto a blank sticker.
- **Check-out:** type or scan the code, see the children on it, the parents, the authorised adults
and any custody notes, record who collected. Anyone with the code can collect. A **wrong code**
is refused, raises an alert on the room screens and is audited; **without the code** needs who
and why (audited).
- **Room screen** (`/dashboard/staff/kids/rooms`): live cards per room — code, allergies, medical
and custody notes, parents and authorised adults — "I'm on duty", counts and full/over-ratio
warnings, **Call parent** (WhatsApp/email, shown on the parent's page), move to another room,
check out without code, wrong-code alerts.
- **Offline desk:** "Get ready for offline" downloads the families and 20 pickup codes reserved for
the device. Offline, the desk checks in (no messages) and checks out children it checked in
itself; "Sync" uploads everything and says if a family got another code elsewhere.
- **Attendance:** each child gets a free place on the kids ticket under the parent the first time
(registration, attendee, ticket), and each check-in is a ticket usage — so the attendance
register, reports and campus scoping include them.
- **Privacy:** allergies, medical and custody notes encrypted, never in messages; custody notes
changed only by parents, supervisors and admins (audited); opening the desk/room screens and
offline downloads are in the access log; a child's profile is deleted after **6 months** without
a check-in (Admin → Privacy → Retention).
---
## Accounts & security
- Register, log in, forgot/reset password, activate account (for accounts
@@ -689,6 +763,7 @@ can't start, the same jobs run on in-process timers.
|------|-----|------|
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
@@ -704,7 +779,7 @@ can't start, the same jobs run on in-process timers.
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/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/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+7 -1
View File
@@ -85,6 +85,12 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
## Campuses
| Setting | Default when unset | Notes |
|---|---|---|
| `campuses_enabled` | `false` | Site settings → Campuses → "Use campuses". Changed only through `PUT /api/campuses/settings` (audited); hidden from the generic settings endpoints. Off hides campus fields and filters everywhere; campuses and each event's campus are kept. The campuses themselves are in the `Campus` table. |
## Cross Events Hub (licence)
| Setting | Default when unset | Notes |
@@ -108,7 +114,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|---|---|---|
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
| `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. |
+72
View File
@@ -847,9 +847,81 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
## Campuses
Multi-campus (`src/utils/campuses.js`), off by default. While off, every campus field and filter
below is ignored and `GET /api/campuses` returns `{ enabled: false, campuses: [] }`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/campuses` | public | `{ enabled, campuses }` — active campuses in order |
| GET | `/api/campuses/by-slug/:slug` | public | One active campus (its `/campus/<slug>` page); 404 when archived or off |
| GET | `/api/campuses/admin` | admin | All campuses incl. archived, with `notifyRecipients` and `_count { events, homeOf }` |
| PUT | `/api/campuses/settings` | admin | `{ enabled }` — audited as `campuses_toggled` (the `campuses_enabled` setting can't be changed through `PUT /api/settings`) |
| POST | `/api/campuses` | admin | `{ name, slug?, address?, contactName?, contactEmail?, contactPhone?, colour? (#rrggbb), logoUrl?, defaultLocation? }`; 409 when the slug is taken |
| PUT | `/api/campuses/:id` | admin | Same fields, plus `notifyRecipientIds` (staff only) and `archived` |
| PUT | `/api/campuses/order` | admin | `{ ids: [...] }` |
| DELETE | `/api/campuses/:id` | admin | Only without events — otherwise `409 CAMPUS_HAS_EVENTS` (archive instead) |
| GET | `/api/campuses/:id/audience` | supervisor+ | `{ campus, users }` — home campus, plus `?registrants=true` people registered for its events in the last 12 months (not cancelled). Broadcasts send to these ids as usual, so news consent still applies |
**Campus-scoped staff (1.44.0):** a supervisor or staff member with campus limits
(`PUT /api/users/:id { scopedCampusIds }`, admin; `GET /api/users/:id` returns `scopedCampuses`;
`GET /api/users/profile` returns `campusScope`, null when unscoped or campuses are off) only sees
their campuses' events plus church-wide ones on every staff/supervisor endpoint
(`src/utils/campusScope.js`). Responses for out-of-scope records:
- `404 { message: "Not found" }` — another campus's event or record (nothing leaks).
- `403 { code: "OTHER_CAMPUS" }` — the scanner (`/api/tickets/scan-preview|qr|scan/:qrCode`), naming the ticket's campus.
- `403 { code: "CHURCH_WIDE_EVENT" }` — changing, cashing up or refunding an event for all campuses.
- `403 { code: "CAMPUS_SCOPED" }` — banner, payment reconciliation, open checkouts, unlinked donations, typed-in broadcast addresses or recipients outside their campuses.
- `409 { code: "CAMPUS_HAS_SCOPED_STAFF" }` on `DELETE /api/campuses/:id` while staff are limited to it.
**Campus on other endpoints:**
- Events carry `campusId` (null = all campuses) and `campus { id, name, slug, colour }`. `POST`/`PUT /api/events` take `campusId` (`''`/`null` = all campuses; an archived campus can stay but can't be newly chosen); moving an event is audited as `event_campus_changed`.
- `GET /api/events?campus=<slug|id>` — that campus's events plus church-wide ones. `GET /api/events/all?campus=<id|slug|all>` — one campus, or church-wide only.
- `GET /api/stats/overview?campus=<id|slug|all>`.
- `PUT /api/users/profile` takes `homeCampusId`; `GET` returns it.
## Kids check-in
Kids check-in (1.45.0, `src/utils/kids.js`). An event has it when `kidsCheckIn` is true with a
`kidsOptionId` (main ticket covering the whole event) and `kidsRoomSetId` (allocation set whose
groups are the rooms; groups take `minAge`, `maxAge`, `ratio` on `POST /api/allocations/sets/:id/groups`
and `PUT /api/allocations/groups/:id`). The **occasion** is today's session on a multi-session event,
or the day (`YYYY-MM-DD`, `APP_TIMEZONE`) on a single-date one; pickup codes are per family per occasion.
Allergies, medical and custody notes are stored encrypted. Staff routes are campus-scoped.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/kids/me` | user | `{ family, qrToken, codes, pages }` — my family (or null), live pickup codes (`{ code, qr, event, children }`) and pages from the last 24 h |
| POST / PUT / DELETE | `/api/kids/me` | user | Start my family `{ name }` / rename it / leave it (the last parent leaving deletes it and its children) |
| POST, PUT, DELETE | `/api/kids/me/children[/:id]` | user | `{ firstName, lastName?, dateOfBirth?, gender?, allergies?, medicalNotes?, custodyNotes? }`; custody changes audited as `kids_custody_notes_changed` |
| POST, PUT, DELETE | `/api/kids/me/adults[/:id]` | user | Authorised adults `{ name, phone?, relationship? }` (up to 10) |
| GET | `/api/kids/setup-events` | supervisor+ | Upcoming events for the setup page |
| GET / PUT | `/api/kids/events/:eventId/settings` | supervisor+ | `{ enabled?, optionId?, roomSetId? }` — `'new'` makes a free "Kids check-in" main ticket or a "Kids rooms" set; switching on needs both. Audited as `kids_settings_updated`; PUT needs a campus the supervisor manages |
| GET | `/api/kids/events` | staff+ | Events with kids check-in, from the last week on, with their sessions |
| GET | `/api/kids/events/:eventId/board?sessionId=&log=1` | staff+ | `{ event, occasion, sessionId, rooms, checkIns, alerts }`. Rooms carry `children`, `leaders`, `leadersNeeded`, `full`, `overRatio`; check-ins carry `code`, allergies, medical and custody notes, the family's parents and adults, and `sticker`. `log=1` writes the access log (`kids_rooms`) |
| GET | `/api/kids/events/:eventId/families?q=` | staff+ | Families by family, child or parent name, email or phone (min. 2 characters) |
| POST | `/api/kids/events/:eventId/families` | staff+ | `{ name?, parent: { name, phone?, email? }, children? }` — the parent's account is matched by email/phone or created inactive; an existing family is reused |
| GET | `/api/kids/events/:eventId/families/qr/:token` | staff+ | The family for a family QR (`FAMILY-<token>`) |
| GET | `/api/kids/events/:eventId/families/:familyId` | staff+ | One family |
| POST | `/api/kids/events/:eventId/families/:familyId/parents` | staff+ | Add a parent (409 if they're in another family) |
| POST, PUT | `/api/kids/events/:eventId/families/:familyId/children[/:childId]` | staff+ | Add/change a child — custody notes only from supervisors and admins |
| POST | `/api/kids/events/:eventId/families/:familyId/adults` | staff+ | Add an authorised adult |
| POST | `/api/kids/events/:eventId/check-in` | staff+ | `{ familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }` → `201 { code, qr, occasion, family, checkIns: [{ id, name, firstName, room, already, sticker }] }`. Messages the parents (and the adult) |
| GET | `/api/kids/events/:eventId/code/:code?sessionId=` | staff+ | `{ code, found, family, children }` for a pickup code (`KIDS-` prefix and case ignored) |
| POST | `/api/kids/events/:eventId/check-out` | staff+ | `{ code, checkInIds?, collectedBy, roomId?, sessionId? }` → `{ checkedOut }`. `404 WRONG_CODE` (alert + audit `kids_wrong_code`) or `404 NOTHING_TO_COLLECT` |
| POST | `/api/kids/check-ins/:id/override` | staff+ | `{ collectedBy, reason }` — check out without the code; audited as `kids_checkout_override` |
| POST | `/api/kids/check-ins/:id/page` | staff+ | `{ message? }` → `{ sent: ['whatsapp'\|'email'], text }` — "Please come to the … room" |
| PUT | `/api/kids/check-ins/:id/room` | staff+ | `{ roomId }` |
| POST | `/api/kids/events/:eventId/duty` | staff+ | `{ roomId, on, sessionId? }` — "I'm on duty" (one room at a time) |
| POST | `/api/kids/alerts/:id/resolve` | staff+ | Mark an alert dealt with |
| GET | `/api/kids/events/:eventId/offline?deviceId=&sessionId=` | staff+ | Offline snapshot: board plus `families` and `codes` (20 reserved for the device). Access log `kids_offline` |
| POST | `/api/kids/events/:eventId/sync` | staff+ | `{ deviceId, occasion, checkIns: [{ clientId, familyId, childId, roomId, code, at }], checkOuts: [{ clientId, collectedBy, at }] }` → `{ checkedIn, checkedOut, conflicts }`; idempotent by `clientId`; audited as `kids_offline_sync` |
## Webhooks
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.42.1",
"version": "1.45.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.42.1",
"version": "1.45.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.42.1",
"version": "1.45.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,76 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_requested';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campuses_toggled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_archived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'campus_deleted';
ALTER TYPE "AdminAuditAction" ADD VALUE 'event_campus_changed';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "campusId" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "homeCampusId" TEXT;
-- CreateTable
CREATE TABLE "Campus" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"address" TEXT,
"contactName" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"colour" TEXT,
"logoUrl" TEXT,
"defaultLocation" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Campus_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_CampusNotifyRecipients" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_CampusNotifyRecipients_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Campus_slug_key" ON "Campus"("slug");
-- CreateIndex
CREATE INDEX "Campus_order_idx" ON "Campus"("order");
-- CreateIndex
CREATE INDEX "_CampusNotifyRecipients_B_index" ON "_CampusNotifyRecipients"("B");
-- CreateIndex
CREATE INDEX "Event_campusId_idx" ON "Event"("campusId");
-- CreateIndex
CREATE INDEX "User_homeCampusId_idx" ON "User"("homeCampusId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_homeCampusId_fkey" FOREIGN KEY ("homeCampusId") REFERENCES "Campus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "Campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CampusNotifyRecipients" ADD CONSTRAINT "_CampusNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_campus_scope_changed';
-- CreateTable
CREATE TABLE "_UserCampusScope" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_UserCampusScope_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_UserCampusScope_B_index" ON "_UserCampusScope"("B");
-- AddForeignKey
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_A_fkey" FOREIGN KEY ("A") REFERENCES "Campus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserCampusScope" ADD CONSTRAINT "_UserCampusScope_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,250 @@
-- CreateEnum
CREATE TYPE "KidsAlertKind" AS ENUM ('wrong_code', 'page_parent', 'override');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_settings_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_checkout_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_wrong_code';
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_custody_notes_changed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'kids_offline_sync';
-- AlterTable
ALTER TABLE "AllocationGroup" ADD COLUMN "maxAge" INTEGER,
ADD COLUMN "minAge" INTEGER,
ADD COLUMN "ratio" INTEGER;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "kidsCheckIn" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "kidsOptionId" TEXT,
ADD COLUMN "kidsRoomSetId" TEXT;
-- CreateTable
CREATE TABLE "Family" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"qrToken" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Family_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMember" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Child" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT,
"dateOfBirth" TIMESTAMP(3),
"gender" TEXT,
"allergies" TEXT,
"medicalNotes" TEXT,
"custodyNotes" TEXT,
"lastCheckInAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Child_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorisedAdult" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"relationship" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuthorisedAdult_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KidsPickupCode" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"occasion" TEXT NOT NULL,
"code" TEXT NOT NULL,
"familyId" TEXT,
"deviceId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "KidsPickupCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KidsCheckIn" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"occasion" TEXT NOT NULL,
"sessionId" TEXT,
"childId" TEXT,
"childName" TEXT NOT NULL,
"roomId" TEXT,
"pickupCodeId" TEXT NOT NULL,
"attendeeId" TEXT,
"ticketId" TEXT,
"checkedInAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkedInById" TEXT,
"checkedOutAt" TIMESTAMP(3),
"checkedOutById" TEXT,
"collectedBy" TEXT,
"overrideReason" TEXT,
"clientId" TEXT,
"offline" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "KidsCheckIn_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KidsAlert" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"occasion" TEXT NOT NULL,
"kind" "KidsAlertKind" NOT NULL,
"roomId" TEXT,
"checkInId" TEXT,
"message" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "KidsAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KidsRoomDuty" (
"id" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"occasion" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "KidsRoomDuty_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Family_qrToken_key" ON "Family"("qrToken");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMember_userId_key" ON "FamilyMember"("userId");
-- CreateIndex
CREATE INDEX "FamilyMember_familyId_idx" ON "FamilyMember"("familyId");
-- CreateIndex
CREATE INDEX "Child_familyId_idx" ON "Child"("familyId");
-- CreateIndex
CREATE INDEX "Child_lastCheckInAt_idx" ON "Child"("lastCheckInAt");
-- CreateIndex
CREATE INDEX "AuthorisedAdult_familyId_idx" ON "AuthorisedAdult"("familyId");
-- CreateIndex
CREATE INDEX "KidsPickupCode_eventId_occasion_deviceId_idx" ON "KidsPickupCode"("eventId", "occasion", "deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_code_key" ON "KidsPickupCode"("eventId", "occasion", "code");
-- CreateIndex
CREATE UNIQUE INDEX "KidsPickupCode_eventId_occasion_familyId_key" ON "KidsPickupCode"("eventId", "occasion", "familyId");
-- CreateIndex
CREATE UNIQUE INDEX "KidsCheckIn_clientId_key" ON "KidsCheckIn"("clientId");
-- CreateIndex
CREATE INDEX "KidsCheckIn_eventId_occasion_idx" ON "KidsCheckIn"("eventId", "occasion");
-- CreateIndex
CREATE INDEX "KidsCheckIn_roomId_idx" ON "KidsCheckIn"("roomId");
-- CreateIndex
CREATE INDEX "KidsCheckIn_pickupCodeId_idx" ON "KidsCheckIn"("pickupCodeId");
-- CreateIndex
CREATE UNIQUE INDEX "KidsCheckIn_eventId_occasion_childId_key" ON "KidsCheckIn"("eventId", "occasion", "childId");
-- CreateIndex
CREATE INDEX "KidsAlert_eventId_occasion_createdAt_idx" ON "KidsAlert"("eventId", "occasion", "createdAt");
-- CreateIndex
CREATE INDEX "KidsAlert_checkInId_idx" ON "KidsAlert"("checkInId");
-- CreateIndex
CREATE INDEX "KidsRoomDuty_roomId_occasion_idx" ON "KidsRoomDuty"("roomId", "occasion");
-- CreateIndex
CREATE INDEX "KidsRoomDuty_userId_idx" ON "KidsRoomDuty"("userId");
-- AddForeignKey
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMember" ADD CONSTRAINT "FamilyMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Child" ADD CONSTRAINT "Child_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthorisedAdult" ADD CONSTRAINT "AuthorisedAdult_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsPickupCode" ADD CONSTRAINT "KidsPickupCode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_childId_fkey" FOREIGN KEY ("childId") REFERENCES "Child"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_pickupCodeId_fkey" FOREIGN KEY ("pickupCodeId") REFERENCES "KidsPickupCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedInById_fkey" FOREIGN KEY ("checkedInById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsCheckIn" ADD CONSTRAINT "KidsCheckIn_checkedOutById_fkey" FOREIGN KEY ("checkedOutById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_checkInId_fkey" FOREIGN KEY ("checkInId") REFERENCES "KidsCheckIn"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsAlert" ADD CONSTRAINT "KidsAlert_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KidsRoomDuty" ADD CONSTRAINT "KidsRoomDuty_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+246
View File
@@ -141,6 +141,53 @@ model User {
noticeAcceptances NoticeAcceptance[]
dataAccessLogs DataAccessLog[]
breachesRecorded SecurityBreach[]
// Multi-campus (only used while Site settings → Campuses is on).
homeCampusId String?
homeCampus Campus? @relation("UserHomeCampus", fields: [homeCampusId], references: [id], onDelete: SetNull)
campusSummaries Campus[] @relation("CampusNotifyRecipients")
// Campus-scoped staff (1.44.0): a supervisor/staff member limited to these campuses (their
// events plus events for all campuses). Empty = unscoped. Ignored for admins and while
// campuses are off (utils/campusScope.js).
scopedCampuses Campus[] @relation("UserCampusScope")
// Kids check-in (1.45.0): the family this person is a parent in (at most one).
familyMembership FamilyMember?
kidsCheckInsDone KidsCheckIn[] @relation("KidsCheckInBy")
kidsCheckOutsDone KidsCheckIn[] @relation("KidsCheckOutBy")
kidsAlertsRaised KidsAlert[]
kidsRoomDuties KidsRoomDuty[]
@@index([homeCampusId])
}
// A campus of the church (Site settings → Campuses, off by default). Events belong to one
// campus, or to all campuses when Event.campusId is null. Archived instead of deleted once
// it has events.
model Campus {
id String @id @default(uuid())
name String
slug String @unique
address String?
contactName String?
contactEmail String?
contactPhone String?
// Accent colour (#rrggbb) and logo shown with the campus on the public site.
colour String?
logoUrl String?
// Pre-fills Event.location when an event is created for this campus.
defaultLocation String?
order Int @default(0)
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events Event[]
homeOf User[] @relation("UserHomeCampus")
// Get the daily summary for this campus's events (on top of each event's own recipients).
notifyRecipients User[] @relation("CampusNotifyRecipients")
scopedStaff User[] @relation("UserCampusScope")
@@index([order])
}
model Event {
@@ -187,6 +234,9 @@ model Event {
contactPhone String?
contactEmail String?
location String?
// Multi-campus: null = all campuses (also every event while campuses are off).
campusId String?
campus Campus? @relation(fields: [campusId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
@@ -243,9 +293,21 @@ model Event {
sessions EventSession[]
certificates Certificate[]
// Kids check-in (1.45.0): children are checked in at the kids desk instead of buying
// tickets. Each child gets a free place on `kidsOptionId` (a main-ticket option of this
// event) the first time, so they show on the attendance register; rooms are the groups of
// the allocation set `kidsRoomSetId`. Plain ids (checked in utils/kids.js), not relations.
kidsCheckIn Boolean @default(false)
kidsOptionId String?
kidsRoomSetId String?
kidsPickupCodes KidsPickupCode[]
kidsCheckIns KidsCheckIn[]
kidsAlerts KidsAlert[]
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@index([campusId])
}
model EventOption {
@@ -603,6 +665,19 @@ enum AdminAuditAction {
breach_recorded
breach_updated
hub_connected
hub_update_requested
campuses_toggled
campus_created
campus_updated
campus_archived
campus_deleted
event_campus_changed
user_campus_scope_changed
kids_settings_updated
kids_checkout_override
kids_wrong_code
kids_custody_notes_changed
kids_offline_sync
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -1276,11 +1351,19 @@ model AllocationGroup {
gender String?
leaderUserId String?
order Int @default(0)
// Kids rooms (1.45.0): the ages the room is for (whole years, inclusive) and how many
// children one leader may look after. All optional.
minAge Int?
maxAge Int?
ratio Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
assignments AllocationAssignment[]
kidsCheckIns KidsCheckIn[]
kidsAlerts KidsAlert[]
kidsDuties KidsRoomDuty[]
@@index([setId])
@@index([leaderUserId])
@@ -1777,3 +1860,166 @@ model SecurityBreach {
@@index([status, discoveredAt])
}
// ─── Kids check-in (1.45.0) ───────────────────────────────────────────────────
// A household whose children are checked in at the kids desk. Kept between events (weekly
// kids church). `qrToken` is what the parent's family QR in My account → Kids holds.
model Family {
id String @id @default(uuid())
name String
qrToken String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members FamilyMember[]
children Child[]
adults AuthorisedAdult[]
codes KidsPickupCode[]
}
// A parent account in a family. A person is a parent in one family at most.
model FamilyMember {
id String @id @default(uuid())
familyId String
userId String @unique
createdAt DateTime @default(now())
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([familyId])
}
// A child in a family. allergies, medicalNotes and custodyNotes are encrypted
// (utils/encryption.js). Deleted by the retention job after months without a check-in.
model Child {
id String @id @default(uuid())
familyId String
firstName String
lastName String?
dateOfBirth DateTime?
gender String?
allergies String?
medicalNotes String?
custodyNotes String?
lastCheckInAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
checkIns KidsCheckIn[]
@@index([familyId])
@@index([lastCheckInAt])
}
// Someone the family allows to collect their children, who needn't have an account (a
// grandparent, a friend). Anyone with the pickup code can collect; this list is what the
// room leader checks the person against, and who the code can be sent to.
model AuthorisedAdult {
id String @id @default(uuid())
familyId String
name String
phone String?
relationship String?
createdAt DateTime @default(now())
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
@@index([familyId])
}
// A pickup code: one per family per occasion (a session, or the day on a single-date event),
// shared by the children checked in together. Codes for offline desks are reserved ahead with
// `deviceId` and no family, and claimed when the desk syncs.
model KidsPickupCode {
id String @id @default(uuid())
eventId String
occasion String
code String
familyId String?
deviceId String?
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
checkIns KidsCheckIn[]
@@unique([eventId, occasion, code])
@@unique([eventId, occasion, familyId])
@@index([eventId, occasion, deviceId])
}
// One child checked in to a room for an occasion. childId is cleared when the child's profile
// is deleted; the attendance (attendee, ticket, ticket usage) stays.
model KidsCheckIn {
id String @id @default(uuid())
eventId String
occasion String
sessionId String?
childId String?
childName String
roomId String?
pickupCodeId String
attendeeId String?
ticketId String?
checkedInAt DateTime @default(now())
checkedInById String?
checkedOutAt DateTime?
checkedOutById String?
collectedBy String?
overrideReason String?
// Offline desks: the device's id for the check-in (makes syncing idempotent).
clientId String? @unique
offline Boolean @default(false)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
child Child? @relation(fields: [childId], references: [id], onDelete: SetNull)
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
pickupCode KidsPickupCode @relation(fields: [pickupCodeId], references: [id], onDelete: Cascade)
checkedInBy User? @relation("KidsCheckInBy", fields: [checkedInById], references: [id], onDelete: SetNull)
checkedOutBy User? @relation("KidsCheckOutBy", fields: [checkedOutById], references: [id], onDelete: SetNull)
alerts KidsAlert[]
@@unique([eventId, occasion, childId])
@@index([eventId, occasion])
@@index([roomId])
@@index([pickupCodeId])
}
enum KidsAlertKind {
wrong_code
page_parent
override
}
// Something the room needs to see: a wrong pickup code, a parent paged, an override.
model KidsAlert {
id String @id @default(uuid())
eventId String
occasion String
kind KidsAlertKind
roomId String?
checkInId String?
message String
createdById String?
createdAt DateTime @default(now())
resolvedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
room AllocationGroup? @relation(fields: [roomId], references: [id], onDelete: SetNull)
checkIn KidsCheckIn? @relation(fields: [checkInId], references: [id], onDelete: SetNull)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([eventId, occasion, createdAt])
@@index([checkInId])
}
// A leader on duty in a kids room for an occasion ("I'm on duty"), for the ratio.
model KidsRoomDuty {
id String @id @default(uuid())
roomId String
occasion String
userId String
startedAt DateTime @default(now())
endedAt DateTime?
room AllocationGroup @relation(fields: [roomId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([roomId, occasion])
@@index([userId])
}
@@ -18,6 +18,13 @@ const cleanCapacity = (v) => {
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
return n;
};
// Kids rooms: ages in whole years and children per leader. Blank = no limit.
const cleanSmallInt = (v, what, min, max) => {
if (v === undefined || v === null || v === '') return null;
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n < min || n > max) throw allocationError(`${what} must be a whole number from ${min} to ${max}.`);
return n;
};
/** Named attendees on the event's live registrations. */
async function eventAttendees(eventId) {
@@ -110,8 +117,14 @@ const deleteSet = async (req, res) => {
};
async function groupData(body, setId, existing = null) {
const { name, capacity, gender, leaderUserId } = body || {};
const { name, capacity, gender, leaderUserId, minAge, maxAge, ratio } = body || {};
const data = {};
if (minAge !== undefined) data.minAge = cleanSmallInt(minAge, 'The youngest age', 0, 25);
if (maxAge !== undefined) data.maxAge = cleanSmallInt(maxAge, 'The oldest age', 0, 25);
if (ratio !== undefined) data.ratio = cleanSmallInt(ratio, 'Children per leader', 1, 100);
const lo = data.minAge !== undefined ? data.minAge : existing?.minAge;
const hi = data.maxAge !== undefined ? data.maxAge : existing?.maxAge;
if (lo != null && hi != null && lo > hi) throw allocationError("The youngest age can't be more than the oldest age.");
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
if (gender !== undefined) data.gender = cleanGender(gender);
@@ -132,7 +145,7 @@ async function groupData(body, setId, existing = null) {
return data;
}
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId?, minAge?, maxAge?, ratio? }
const createGroup = async (req, res) => {
try {
const set = await loadSet(req.params.id);
@@ -21,9 +21,13 @@ const trackStage = async (req, res) => {
// @access Private/Supervisor
const getFunnel = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
const asked = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (asked.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
// Campus-scoped supervisors only get the events they can see.
const scopeWhere = await require('../utils/campusScope').listWhere(req);
const events = await prisma.event.findMany({ where: { id: { in: asked }, ...scopeWhere }, select: { id: true, title: true } });
const eventIds = events.map(e => e.id);
if (eventIds.length === 0) { res.status(404); throw new Error('Event not found'); }
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
// Per event too, so events can be compared.
const perEvent = [];
+193
View File
@@ -0,0 +1,193 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
const audit = (req, action, campus, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
});
// A slug that's already taken → a message instead of a Prisma error.
function friendlyError(e) {
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
return e.message || safeErrorMessage(e);
}
const ADMIN_INCLUDE = {
notifyRecipients: { select: { id: true, name: true, email: true } },
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
};
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
// @route GET /api/campuses
// @access Public
const getCampuses = async (req, res) => {
try {
const enabled = await campusesEnabled();
const campuses = enabled
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
: [];
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc A campus landing page: the campus (active only)
// @route GET /api/campuses/by-slug/:slug
// @access Public
const getCampusBySlug = async (req, res) => {
try {
const campus = (await campusesEnabled())
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
: null;
if (!campus) return res.status(404).json({ message: 'Campus not found' });
res.json(campus);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
// @route GET /api/campuses/admin
// @access Private/Admin
const getCampusesAdmin = async (req, res) => {
try {
const [enabled, campuses] = await Promise.all([
campusesEnabled(),
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
]);
res.json({ enabled, campuses });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
// @route PUT /api/campuses/settings body: { enabled }
// @access Private/Admin
const updateCampusSettings = async (req, res) => {
try {
const enabled = req.body?.enabled === true;
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
invalidateSettingsCache();
await warmCache();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
res.json({ enabled });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Add a campus (added at the end of the list)
// @route POST /api/campuses
// @access Private/Admin
const createCampus = async (req, res) => {
try {
const { data, error } = cleanCampusInput(req.body);
if (error) return res.status(400).json({ message: error });
const last = await prisma.campus.aggregate({ _max: { order: true } });
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
audit(req, 'campus_created', campus, { slug: campus.slug });
res.status(201).json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Change a campus: details, summary recipients, archived or not
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
// @access Private/Admin
const updateCampus = async (req, res) => {
try {
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ message: 'Campus not found' });
const { data, error } = cleanCampusInput(req.body, { partial: true });
if (error) return res.status(400).json({ message: error });
if (Array.isArray(req.body?.notifyRecipientIds)) {
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
// Summaries go to people who run events — never to ordinary users.
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
}
let archiveChange = null;
if (req.body?.archived !== undefined) {
const archived = req.body.archived === true;
if (archived !== !!existing.archivedAt) {
data.archivedAt = archived ? new Date() : null;
archiveChange = archived;
}
}
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
if (changed.length) audit(req, 'campus_updated', campus, { changed });
res.json(campus);
} catch (e) {
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
}
};
// @desc Put the campuses in this order (filters and lists follow it)
// @route PUT /api/campuses/order body: { ids: [...] }
// @access Private/Admin
const reorderCampuses = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
res.json({ ok: true });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
// @route DELETE /api/campuses/:id
// @access Private/Admin
const deleteCampus = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
// Deleting would drop their limit and leave them unscoped (seeing every campus).
if (campus._count.scopedStaff > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
}
if (campus._count.events > 0) {
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
}
await prisma.campus.delete({ where: { id: campus.id } });
audit(req, 'campus_deleted', campus, { slug: campus.slug });
res.json({ message: 'Campus deleted' });
} catch (e) {
res.status(400).json({ message: safeErrorMessage(e) });
}
};
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
// ?registrants=true) people registered for X's events in the last 12 months. The
// composer sends to these ids as usual, so news consent is still checked on sending.
// @route GET /api/campuses/:id/audience?registrants=true
// @access Private/Supervisor
const getCampusAudience = async (req, res) => {
try {
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
if (!campus) return res.status(404).json({ message: 'Campus not found' });
const scope = await require('../utils/campusScope').scopeFor(req);
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
const users = await prisma.user.findMany({
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
select: { id: true, name: true, email: true, phoneNumber: true },
orderBy: { name: 'asc' },
});
res.json({ campus, users });
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
@@ -291,9 +291,12 @@ const resolvePending = async (req, res) => {
const getCashupAudit = async (req, res) => {
try {
const { eventId, from, to } = req.query;
// Campus-scoped supervisors only see cashups they could do: their campuses' events.
const scope = await require('../utils/campusScope').scopeFor(req);
const where = {};
if (eventId) where.eventId = eventId;
if (scope) where.event = require('../utils/campusScope').manageWhere(scope);
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
+24 -7
View File
@@ -5,18 +5,29 @@ const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
async function eventIdsAllowed(scope, eventIds) {
if (!scope) return true;
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
}
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
@@ -119,7 +130,9 @@ function relationWrites({ eventIds, scopes, userIds }, isCreate) {
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
const scope = await scopeFor(req);
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
.filter(c => couponAllowed(scope, couponCampuses(c)));
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
@@ -157,7 +170,7 @@ const getCoupon = async (req, res) => {
},
},
});
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -173,6 +186,7 @@ const createCoupon = async (req, res) => {
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
@@ -198,12 +212,14 @@ const createCoupon = async (req, res) => {
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
const scope = await scopeFor(req);
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
@@ -327,9 +343,10 @@ const listRedemptions = async (req, res) => {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
@@ -346,7 +363,7 @@ const listRedemptions = async (req, res) => {
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
+54 -7
View File
@@ -8,6 +8,20 @@ const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
async function assertCampusAllowed(req, campusId) {
const scope = await scopeFor(req);
if (scope && !canManage(scope, campusId)) {
const err = new Error(campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
err.statusCode = 403;
throw err;
}
}
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
@@ -125,6 +139,11 @@ const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
const data = {
id: uuidv4(),
title,
@@ -160,6 +179,7 @@ const createEvent = async (req, res) => {
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
@@ -325,15 +345,19 @@ const getEvents = async (req, res) => {
// Determine if Prisma Client supports EarlyBirdTier relation (i.e., migration+generate applied)
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const eventOptionsInclude = canIncludeTiers ? { include: { earlyBirdTiers: true } } : true;
// ?campus=<slug or id>: that campus's events plus church-wide ones.
const enabled = await campusesEnabled();
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
try {
events = await prisma.event.findMany({
where: {
isActive: true,
isHidden: false,
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() }
endDate: { gte: new Date() },
...campusFilter,
},
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
@@ -383,6 +407,7 @@ const getAllEvents = async (req, res) => {
include: {
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
}
});
@@ -413,6 +438,10 @@ const getEventsAll = async (req, res) => {
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campusesEnabled();
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
const scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -420,11 +449,12 @@ const getEventsAll = async (req, res) => {
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const events = await prisma.event.findMany({
where,
where: scoped,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
},
orderBy: { startDate: 'asc' }
});
@@ -452,7 +482,7 @@ const getEventById = async (req, res) => {
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
@@ -496,9 +526,12 @@ const getEventById = async (req, res) => {
}
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
// who need to load inactive events for cashup, editing, etc.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
// public view of other campuses' events (and not their notification recipients).
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
delete event.createdBy;
delete event.notifyRecipients;
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
@@ -627,7 +660,7 @@ const getEventIcs = async (req, res) => {
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
// details shouldn't be enumerable via this route either.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
@@ -688,6 +721,13 @@ const updateEvent = async (req, res) => {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) throw new Error(campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
if (campusChanged) {
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
}
const data = {
title: title || event.title,
description: description !== undefined ? description : event.description,
@@ -725,6 +765,7 @@ const updateEvent = async (req, res) => {
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
...(campusChanged ? { campusId: campusPick.campusId } : {}),
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
@@ -749,6 +790,12 @@ const updateEvent = async (req, res) => {
: null;
});
sessionsUtil.forgetSchedule(updatedEvent.id);
if (campusChanged) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req),
});
}
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
@@ -151,7 +151,7 @@ const getSummary = async (req, res) => {
try {
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
const forms = await prisma.feedbackForm.findMany({
where: { eventId: { in: ids } },
where: { eventId: { in: ids }, ...(await require('../utils/campusScope').listWhere(req, 'event')) },
include: {
event: { select: { id: true, title: true, startDate: true } },
fields: { orderBy: { order: 'asc' } },
@@ -16,6 +16,7 @@ const listFormResponses = async (req, res) => {
const registrationFilter = {};
if (eventId) registrationFilter.eventId = String(eventId);
if (userId) registrationFilter.userId = String(userId);
Object.assign(registrationFilter, await require('../utils/campusScope').listWhere(req, 'event'));
if (Object.keys(registrationFilter).length > 0) {
// Prisma relation filter requires `is` wrapper
where.registration = { is: registrationFilter };
+448
View File
@@ -0,0 +1,448 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { logDataAccess } = require('../utils/accessLog');
const { findOrCreateAccount } = require('../utils/accountMatch');
const { listWhere, withScope } = require('../utils/campusScope');
const kids = require('../utils/kids');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && typeof error.code === 'string' && !error.code.startsWith('P') ? { code: error.code } : {}) });
const audit = (req, action, targetType, targetId, metadata) => logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action, targetType, targetId, metadata, ip: getClientIp(req) });
/** Child fields from a request; only supervisors, admins and the child's parents set custody notes. */
function childInput(req, { parent = false } = {}) {
const body = { ...(req.body || {}) };
if (!parent && !SUPERVISORS.includes(req.user.role)) delete body.custodyNotes;
return body;
}
async function saveChild(req, familyId, childId, { parent }) {
const existing = childId ? await prisma.child.findFirst({ where: { id: String(childId), familyId } }) : null;
if (childId && !existing) throw kids.kidsError('Child not found', 404);
const data = kids.cleanChild(childInput(req, { parent }), existing);
const child = existing
? await prisma.child.update({ where: { id: existing.id }, data })
: await prisma.child.create({ data: { ...data, familyId } });
if (kids.custodyChanged(existing, data)) audit(req, 'kids_custody_notes_changed', 'child', child.id, { familyId, by: parent ? 'parent' : 'staff' });
return child;
}
// ─── My account → Kids (parents) ──────────────────────────────────────────────
// @route GET /api/kids/me
const getMine = async (req, res) => {
try {
const family = await kids.familyOfUser(req.user.id);
if (!family) return res.json({ family: null, qrToken: null, codes: [], pages: [] });
const active = await kids.activeForFamily(family.id);
res.json({ family: kids.familyView(family), qrToken: family.qrToken, ...active });
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/me { name }
const createMine = async (req, res) => {
try {
const family = await kids.createFamily({ name: req.body?.name || `${req.user.name || 'My'} family`, userId: req.user.id });
res.status(201).json({ family: kids.familyView(family), qrToken: family.qrToken, codes: [], pages: [] });
} catch (error) {
fail(res, error);
}
};
async function myFamilyId(req) {
const m = await prisma.familyMember.findUnique({ where: { userId: req.user.id } });
if (!m) throw kids.kidsError('Add your family first.', 404);
return m.familyId;
}
// @route PUT /api/kids/me { name }
const renameMine = async (req, res) => {
try {
const id = await myFamilyId(req);
const name = String(req.body?.name || '').trim().slice(0, 100);
if (!name) throw kids.kidsError('Enter a name for the family.');
await prisma.family.update({ where: { id }, data: { name } });
res.json(kids.familyView(await kids.loadFamily(id)));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/me/children, PUT /api/kids/me/children/:id
const saveMyChild = async (req, res) => {
try {
const id = await myFamilyId(req);
const child = await saveChild(req, id, req.params.id, { parent: true });
res.status(req.params.id ? 200 : 201).json(kids.childView(child));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/kids/me/children/:id
const deleteMyChild = async (req, res) => {
try {
const id = await myFamilyId(req);
const out = await prisma.child.deleteMany({ where: { id: String(req.params.id), familyId: id } });
if (!out.count) throw kids.kidsError('Child not found', 404);
res.json({ message: 'Deleted' });
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/me/adults, PUT /api/kids/me/adults/:id
const saveMyAdult = async (req, res) => {
try {
const id = await myFamilyId(req);
const data = kids.cleanAdult(req.body);
if (req.params.id) {
const out = await prisma.authorisedAdult.updateMany({ where: { id: String(req.params.id), familyId: id }, data });
if (!out.count) throw kids.kidsError('Not found', 404);
return res.json(await prisma.authorisedAdult.findUnique({ where: { id: String(req.params.id) } }));
}
if (await prisma.authorisedAdult.count({ where: { familyId: id } }) >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
res.status(201).json(await prisma.authorisedAdult.create({ data: { ...data, familyId: id } }));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/kids/me/adults/:id
const deleteMyAdult = async (req, res) => {
try {
const id = await myFamilyId(req);
const out = await prisma.authorisedAdult.deleteMany({ where: { id: String(req.params.id), familyId: id } });
if (!out.count) throw kids.kidsError('Not found', 404);
res.json({ message: 'Deleted' });
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/kids/me (leave the family; the last parent leaving deletes it)
const leaveMine = async (req, res) => {
try {
await myFamilyId(req);
const out = await prisma.$transaction(tx => kids.removeFromFamily(tx, req.user.id));
res.json(out);
} catch (error) {
fail(res, error);
}
};
// ─── Staff: the desk and the rooms ────────────────────────────────────────────
// @desc Events with kids check-in that are on now or soon (and the last week's)
// @route GET /api/kids/events
const listEvents = async (req, res) => {
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const events = await prisma.event.findMany({
where: withScope({ kidsCheckIn: true, endDate: { gte: since } }, await listWhere(req)),
select: {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true,
sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
},
orderBy: { startDate: 'asc' },
take: 100,
});
res.json(events);
} catch (error) {
fail(res, error);
}
};
// @route GET /api/kids/events/:eventId/board?sessionId=&log=1
const getBoard = async (req, res) => {
try {
const data = await kids.board({ eventId: req.params.eventId, sessionId: req.query.sessionId || null });
// Polling refreshes don't log; opening the screen does (it shows medical and custody notes).
if (req.query.log === '1') {
logDataAccess(req, { action: 'kids_rooms', eventId: data.event.id, records: data.checkIns.length, sensitive: true });
}
res.json(data);
} catch (error) {
fail(res, error);
}
};
// @route GET /api/kids/events/:eventId/families?q=
const searchFamilies = async (req, res) => {
try {
const rows = await kids.searchFamilies(req.query.q);
res.json(rows.map(f => kids.familyView(f)));
} catch (error) {
fail(res, error);
}
};
// @route GET /api/kids/events/:eventId/families/qr/:token
const familyByQr = async (req, res) => {
try {
const raw = String(req.params.token || '').trim().replace(/^FAMILY-/i, '');
const f = await prisma.family.findUnique({ where: { qrToken: raw } });
if (!f) throw kids.kidsError('That isn\'t a family QR code from this site.', 404);
res.json(kids.familyView(await kids.loadFamily(f.id)));
} catch (error) {
fail(res, error);
}
};
// @route GET /api/kids/events/:eventId/families/:familyId
const getFamily = async (req, res) => {
try {
res.json(kids.familyView(await kids.loadFamily(req.params.familyId)));
} catch (error) {
fail(res, error);
}
};
/** A parent from the desk: their account (matched by email/phone, or a new inactive one). */
async function parentAccount(input) {
const name = String(input?.name || '').trim().slice(0, 100);
if (!name) throw kids.kidsError('Enter the parent\'s name.');
const email = String(input?.email || '').trim();
const phone = String(input?.phone || '').trim();
if (!email && !phone) throw kids.kidsError('Enter the parent\'s phone number or email address, so the pickup code can be sent to them.');
const { user } = await findOrCreateAccount({ name, email, phone });
return user;
}
// @desc New family at the desk: a parent (found or created) and their children
// @route POST /api/kids/events/:eventId/families { name, parent: { name, phone, email }, children: [...] }
const createFamilyAtDesk = async (req, res) => {
try {
const user = await parentAccount(req.body?.parent);
const existing = await kids.familyOfUser(user.id);
// Don't tell the desk anything about an account beyond what they typed: if the person is
// already in a family, that family is used (they're standing at the desk).
const family = existing || await kids.createFamily({ name: req.body?.name || `${user.name.split(' ').slice(-1)[0]} family`, userId: user.id });
for (const c of (Array.isArray(req.body?.children) ? req.body.children : []).slice(0, 12)) {
await saveChild({ ...req, body: c }, family.id, null, { parent: false });
}
res.status(existing ? 200 : 201).json(kids.familyView(await kids.loadFamily(family.id)));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/families/:familyId/parents { name, phone, email }
const addParentAtDesk = async (req, res) => {
try {
const family = await kids.loadFamily(req.params.familyId);
const user = await parentAccount(req.body);
const m = await prisma.familyMember.findUnique({ where: { userId: user.id } });
if (m && m.familyId !== family.id) throw kids.kidsError('This person is already a parent in another family.', 409);
if (!m) await prisma.familyMember.create({ data: { familyId: family.id, userId: user.id } });
res.json(kids.familyView(await kids.loadFamily(family.id)));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/families/:familyId/children, PUT .../children/:id
const saveChildAtDesk = async (req, res) => {
try {
const family = await kids.loadFamily(req.params.familyId);
const child = await saveChild(req, family.id, req.params.childId, { parent: false });
res.status(req.params.childId ? 200 : 201).json(kids.childView(child));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/families/:familyId/adults { name, phone, relationship }
const addAdultAtDesk = async (req, res) => {
try {
const family = await kids.loadFamily(req.params.familyId);
if (family.adults.length >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
await prisma.authorisedAdult.create({ data: { ...kids.cleanAdult(req.body), familyId: family.id } });
res.status(201).json(kids.familyView(await kids.loadFamily(family.id)));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/check-in { familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }
const checkIn = async (req, res) => {
try {
const out = await kids.checkIn({
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, familyId: req.body?.familyId,
children: req.body?.children, staffUserId: req.user.id,
});
const fresh = out.checkIns.filter(c => !out.already.includes(c.id));
if (fresh.length) kids.sendCheckInMessages({ event: out.event, family: out.family, code: out.code, checkIns: fresh, adultId: req.body?.sendToAdultId || null }).catch(() => {});
res.status(201).json({
code: out.code,
qr: kids.QR_PREFIX + out.code,
occasion: out.occasion,
family: { id: out.family.id, name: out.family.name },
checkIns: out.checkIns.map(c => {
const child = c.child ? kids.childView(c.child) : null;
return {
id: c.id, name: c.childName, firstName: child?.firstName || c.childName, room: c.room, already: out.already.includes(c.id),
sticker: kids.stickerAlert(child?.allergies, child?.medicalNotes),
};
}),
});
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/check-out { code, checkInIds?, collectedBy, roomId?, sessionId? }
const checkOut = async (req, res) => {
try {
const out = await kids.checkOut({
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, code: req.body?.code, checkInIds: req.body?.checkInIds,
collectedBy: req.body?.collectedBy, roomId: req.body?.roomId || null, staffUserId: req.user.id,
});
res.json({ checkedOut: out.checkIns.map(c => ({ id: c.id, name: c.childName, room: c.room?.name || null })) });
} catch (error) {
if (error.code === 'WRONG_CODE') audit(req, 'kids_wrong_code', 'event', req.params.eventId, { typed: String(req.body?.code || '').slice(0, 20), roomId: req.body?.roomId || null });
fail(res, error);
}
};
// @desc Look up a code before checking out: which children are on it (to confirm who's collected)
// @route GET /api/kids/events/:eventId/code/:code?sessionId=
const lookupCode = async (req, res) => {
try {
const event = await kids.loadKidsEvent(req.params.eventId);
const { occasion } = kids.occasionFor(event, event.sessions, req.query.sessionId || null);
const code = kids.normaliseCode(req.params.code);
const row = code ? await prisma.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
if (!row) return res.json({ code, found: false, children: [] });
const rows = await prisma.kidsCheckIn.findMany({ where: { pickupCodeId: row.id }, include: { room: { select: { id: true, name: true } } }, orderBy: { childName: 'asc' } });
const family = row.familyId ? kids.familyView(await kids.loadFamily(row.familyId), { notes: false }) : null;
res.json({
code, found: true,
family: family ? { name: family.name, parents: family.parents, adults: family.adults } : null,
children: rows.map(c => ({ id: c.id, name: c.childName, room: c.room, checkedOutAt: c.checkedOutAt, collectedBy: c.collectedBy })),
});
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/check-ins/:id/override { reason, collectedBy }
const overrideCheckOut = async (req, res) => {
try {
const ci = await kids.overrideCheckOut({ checkInId: req.params.id, reason: req.body?.reason, collectedBy: req.body?.collectedBy, staffUserId: req.user.id });
audit(req, 'kids_checkout_override', 'kids_check_in', ci.id, { child: ci.childName, collectedBy: ci.collectedBy, reason: ci.overrideReason });
res.json({ id: ci.id, checkedOutAt: ci.checkedOutAt });
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/check-ins/:id/page { message? }
const pageParent = async (req, res) => {
try {
res.json(await kids.pageParent({ checkInId: req.params.id, message: req.body?.message, staffUserId: req.user.id }));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/kids/check-ins/:id/room { roomId }
const moveRoom = async (req, res) => {
try {
const ci = await kids.moveRoom({ checkInId: req.params.id, roomId: req.body?.roomId });
res.json({ id: ci.id, roomId: ci.roomId });
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/duty { roomId, on, sessionId? }
const setDuty = async (req, res) => {
try {
res.json(await kids.setDuty({ eventId: req.params.eventId, sessionId: req.body?.sessionId || null, roomId: req.body?.roomId, userId: req.user.id, on: req.body?.on !== false }));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/alerts/:id/resolve
const resolveAlert = async (req, res) => {
try {
const out = await prisma.kidsAlert.updateMany({ where: { id: String(req.params.id), resolvedAt: null }, data: { resolvedAt: new Date() } });
res.json({ resolved: out.count });
} catch (error) {
fail(res, error);
}
};
// @route GET /api/kids/events/:eventId/offline?deviceId=&sessionId=
const offlineSnapshot = async (req, res) => {
try {
const data = await kids.offlineSnapshot({ eventId: req.params.eventId, sessionId: req.query.sessionId || null, deviceId: req.query.deviceId });
logDataAccess(req, { action: 'kids_offline', eventId: req.params.eventId, records: data.families.length, sensitive: true });
res.json(data);
} catch (error) {
fail(res, error);
}
};
// @route POST /api/kids/events/:eventId/sync { deviceId, occasion, checkIns, checkOuts }
const syncOffline = async (req, res) => {
try {
const out = await kids.syncOffline({ eventId: req.params.eventId, ...(req.body || {}), staffUserId: req.user.id });
if (out.checkedIn || out.checkedOut || out.conflicts.length) audit(req, 'kids_offline_sync', 'event', req.params.eventId, { checkedIn: out.checkedIn, checkedOut: out.checkedOut, conflicts: out.conflicts.length });
res.json(out);
} catch (error) {
fail(res, error);
}
};
// ─── Supervisor: setup ────────────────────────────────────────────────────────
// @route GET /api/kids/events/:eventId/settings
const getSettings = async (req, res) => {
try {
res.json(await kids.getSettings(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/kids/events/:eventId/settings { enabled?, optionId?, roomSetId? } ('new' makes one)
const saveSettings = async (req, res) => {
try {
const { enabled, optionId, roomSetId } = req.body || {};
const out = await kids.saveSettings(req.params.eventId, { enabled, optionId, roomSetId });
audit(req, 'kids_settings_updated', 'event', out.event.id, { enabled: out.event.kidsCheckIn, optionId: out.event.kidsOptionId, roomSetId: out.event.kidsRoomSetId });
res.json(out);
} catch (error) {
fail(res, error);
}
};
// @desc Events a supervisor can switch kids check-in on for (upcoming, newest first)
// @route GET /api/kids/setup-events
const setupEvents = async (req, res) => {
try {
const events = await prisma.event.findMany({
where: withScope({ endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, await listWhere(req)),
select: { id: true, title: true, startDate: true, kidsCheckIn: true, campusId: true },
orderBy: { startDate: 'asc' },
take: 200,
});
res.json(events);
} catch (error) {
fail(res, error);
}
};
module.exports = {
getMine, createMine, renameMine, saveMyChild, deleteMyChild, saveMyAdult, deleteMyAdult, leaveMine,
listEvents, getBoard, searchFamilies, familyByQr, getFamily, createFamilyAtDesk, addParentAtDesk, saveChildAtDesk, addAdultAtDesk,
checkIn, checkOut, lookupCode, overrideCheckOut, pageParent, moveRoom, setDuty, resolveAlert, offlineSnapshot, syncOffline,
getSettings, saveSettings, setupEvents,
};
+6 -2
View File
@@ -351,9 +351,13 @@ const getPayments = async (req, res) => {
event: true
};
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count()
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
]);
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
@@ -565,8 +565,10 @@ const getRegistrations = async (req, res) => {
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
// array of every registration) working unchanged; callers that pass either param get back
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
// Campus-scoped staff: registrations for events they can see.
const where = await require('../utils/campusScope').listWhere(req, 'event');
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ include });
const registrations = await prisma.registration.findMany({ where, include });
return res.json(attachComputedTotalsToList(registrations));
}
@@ -575,8 +577,8 @@ const getRegistrations = async (req, res) => {
const skip = (page - 1) * limit;
const [registrations, total] = await prisma.$transaction([
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.registration.count()
prisma.registration.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.registration.count({ where })
]);
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
@@ -1,4 +1,20 @@
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
const prisma = require('../config/db');
const { scopeFor, eventWhere } = require('../utils/campusScope');
/**
* The jobs a campus-scoped supervisor may see and change: messages for events they can see, and
* broadcasts they scheduled themselves. Everyone else sees every job.
*/
async function visibleJobs(req, jobs) {
const scope = await scopeFor(req);
if (!scope) return jobs;
const ids = [...new Set(jobs.map(j => j.eventId).filter(Boolean))];
const seen = ids.length
? new Set((await prisma.event.findMany({ where: { id: { in: ids }, ...eventWhere(scope) }, select: { id: true } })).map(e => e.id))
: new Set();
return jobs.filter(j => (j.eventId ? seen.has(j.eventId) : j.createdById === req.user.id));
}
// Normalize job for client UI
function toClient(job) {
@@ -34,7 +50,7 @@ function toClient(job) {
const listScheduledEmails = async (req, res) => {
try {
const { channel } = req.query || {};
const raw = listJobs(channel ? { channel: String(channel) } : {});
const raw = await visibleJobs(req, listJobs(channel ? { channel: String(channel) } : {}));
const filtered = raw
// Provide most-relevant first: queued -> sending -> error -> recent sent
.sort((a, b) => {
@@ -59,7 +75,7 @@ const updateScheduledEmail = async (req, res) => {
try {
const { id } = req.params;
const job = getJob(id);
if (!job) return res.status(404).json({ message: 'Job not found' });
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
const { scheduledAt, subject, html, text, message } = req.body || {};
@@ -92,7 +108,7 @@ const deleteScheduledEmail = async (req, res) => {
try {
const { id } = req.params;
const job = getJob(id);
if (!job) return res.status(404).json({ message: 'Job not found' });
if (!job || !(await visibleJobs(req, [job])).length) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
const ok = deleteJob(id);
if (!ok) return res.status(404).json({ message: 'Job not found' });
+2 -3
View File
@@ -10,9 +10,8 @@ const getSections = async (req, res) => {
try {
const { eventId } = req.query;
const where = eventId && eventId !== 'all'
? { eventId }
: undefined;
const { withScope, listWhere } = require('../utils/campusScope');
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
const sections = await prisma.section.findMany({
where,
@@ -18,7 +18,9 @@ const PAYMENT_PRIVATE_KEYS = new Set([
// The Hub connection (install id and secret, licence token) is only written by src/hub —
// an admin editing it here could borrow another install's licence.
const HUB_PRIVATE_KEYS = new Set(require('../hub/client').HUB_KEYS);
const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS]);
// Changed through /api/campuses/settings (audited as campuses_toggled), read through GET /api/campuses.
const CAMPUS_KEYS = new Set([require('../utils/campuses').SETTING_KEY]);
const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS, ...CAMPUS_KEYS]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
+52 -27
View File
@@ -1,21 +1,23 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { isDonationLeg } = require('../utils/cashupUtils');
const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses');
const { scopeFor, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
// computed with aggregate queries — never a full payments/events list shipped to the
// client just to be reduced down to a couple of numbers.
async function computeScanStats(userId) {
async function computeScanStats(userId, ticketWhere = {}) {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const whereBase = { scannedAt: { gte: startOfDay } };
const whereBase = { scannedAt: { gte: startOfDay }, ...ticketWhere };
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
prisma.ticketUsage.count({ where: whereBase }),
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }, ...ticketWhere } }),
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
]);
@@ -33,8 +35,9 @@ async function computeScanStats(userId) {
};
}
function getRecentScans(limit = 10) {
function getRecentScans(limit = 10, ticketWhere = {}) {
return prisma.ticketUsage.findMany({
where: ticketWhere,
orderBy: { scannedAt: 'desc' },
take: limit,
select: {
@@ -53,8 +56,26 @@ function getRecentScans(limit = 10) {
});
}
function getActiveEventsCount() {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
function getActiveEventsCount(eventWhere = {}) {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } });
}
/**
* `where` pieces for a ?campus= filter (id, slug or 'all') together with the user's campus scope
* (1.44.0), or empty for church-wide. A payment belongs to a campus through its event, or its
* registration's event — so payments with neither (unlinked donations) aren't counted for a
* scoped user. EXCLUDE_DONATION_LEGS uses NOT, so the payment OR never collides with it.
*/
async function campusFilters(value, scope = null) {
const enabled = await campusesEnabled();
const eventWhere = withScope(campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }), scopeEventWhere(scope));
if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {}, usage: {} };
return {
eventWhere,
byEvent: { event: eventWhere },
payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] },
usage: { ticket: { event: eventWhere } },
};
}
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
@@ -64,19 +85,19 @@ const EXCLUDE_DONATION_LEGS = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
};
async function computePaymentStats({ includeWeekMonth }) {
async function computePaymentStats({ includeWeekMonth, paymentWhere = {} }) {
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
const queries = [
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay }, ...paymentWhere } }),
];
if (includeWeekMonth) {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
queries.push(
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }),
);
}
@@ -98,9 +119,10 @@ async function computePaymentStats({ includeWeekMonth }) {
// @access Private/Staff+
const getStaffDashboardStats = async (req, res) => {
try {
const cf = await campusFilters('', await scopeFor(req));
const [scanStats, recentScans] = await Promise.all([
computeScanStats(req.user.id),
getRecentScans(10),
computeScanStats(req.user.id, cf.usage),
getRecentScans(10, cf.usage),
]);
res.json({ scanStats, recentScans });
} catch (error) {
@@ -113,11 +135,12 @@ const getStaffDashboardStats = async (req, res) => {
// @access Private/Supervisor+
const getSupervisorDashboardStats = async (req, res) => {
try {
const cf = await campusFilters('', await scopeFor(req));
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
computeScanStats(req.user.id),
getRecentScans(10),
computePaymentStats({ includeWeekMonth: false }),
getActiveEventsCount(),
computeScanStats(req.user.id, cf.usage),
getRecentScans(10, cf.usage),
computePaymentStats({ includeWeekMonth: false, paymentWhere: cf.payment }),
getActiveEventsCount(cf.eventWhere),
]);
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
} catch (error) {
@@ -207,21 +230,22 @@ function classifyAndSum(payments) {
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
// month) vs the month before that, a daily revenue trend for the trailing month,
// and the top performing events overall.
// @route GET /api/stats/overview
// @route GET /api/stats/overview?campus=<id|slug|all> (no campus = church-wide)
// @access Private/Supervisor+
const getOverviewStats = async (req, res) => {
try {
const now = new Date();
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
const cf = await campusFilters(req.query.campus, await scopeFor(req));
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
getActiveEventsCount(),
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }),
getActiveEventsCount(cf.eventWhere),
]);
const thisTotals = classifyAndSum(thisPayments);
@@ -245,7 +269,7 @@ const getOverviewStats = async (req, res) => {
// "real revenue" definition as the KPI above: everything except application legs.
const revenueByEvent = await prisma.payment.groupBy({
by: ['eventId'],
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null }, ...cf.byEvent },
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 5,
@@ -270,7 +294,7 @@ const getOverviewStats = async (req, res) => {
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now }, ...cf.eventWhere }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
@@ -296,6 +320,7 @@ const getOverviewStats = async (req, res) => {
};
module.exports = {
campusFilters,
getStaffDashboardStats,
getSupervisorDashboardStats,
getAdminDashboardStats,
+21 -7
View File
@@ -50,6 +50,8 @@ const getTickets = async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
const skip = (page - 1) * limit;
// Campus-scoped supervisors: tickets for events they can see.
const scopeW = await require('../utils/campusScope').listWhere(req, 'event');
const include = {
registrationOption: {
@@ -66,8 +68,8 @@ const getTickets = async (req, res) => {
};
const [tickets, total] = await prisma.$transaction([
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.ticket.count()
prisma.ticket.findMany({ where: scopeW, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.ticket.count({ where: scopeW })
]);
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
@@ -103,7 +105,7 @@ const getUserTickets = async (req, res) => {
},
},
user: { select: { id: true, name: true } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, campus: { select: { name: true, slug: true } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
@@ -632,9 +634,11 @@ const getRecentScans = async (req, res) => {
try {
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
const eventId = req.query.eventId || undefined;
// Campus-scoped staff: scans of tickets for events they can see.
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
const scans = await prisma.ticketUsage.findMany({
where: eventId ? { ticket: { eventId } } : undefined,
where: eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : undefined,
orderBy: { scannedAt: 'desc' },
take: limit,
select: {
@@ -666,11 +670,14 @@ const getScanStats = async (req, res) => {
const eventId = req.query.eventId || undefined;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
// Campus-scoped staff: scans of tickets for events they can see.
const scopeEv = (await require('../utils/campusScope').listWhere(req, 'event')).event;
const ticketWhere = eventId || scopeEv ? { ticket: { ...(eventId ? { eventId } : {}), ...(scopeEv ? { event: scopeEv } : {}) } } : {};
// Total scans today (optionally by event)
const whereBase = {
scannedAt: { gte: startOfDay },
...(eventId ? { ticket: { eventId } } : {})
...ticketWhere
};
const [totalToday, myToday, lastHour] = await Promise.all([
@@ -678,7 +685,7 @@ const getScanStats = async (req, res) => {
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
prisma.ticketUsage.count({
where: {
...(eventId ? { ticket: { eventId } } : {}),
...ticketWhere,
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
}
})
@@ -929,6 +936,12 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
// Campus (only included while campuses are on)
if (ticket.event.campus?.name) {
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(ticket.event.campus.name, x + pad, cy, { width: innerW });
cy += doc.heightOfString(ticket.event.campus.name, { width: innerW }) + 2;
}
// Ticket type
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
@@ -983,8 +996,9 @@ async function emailTicketsInternal(req, res, userOverride) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const showCampus = await require('../utils/campuses').campusesEnabled();
const ticketInclude = {
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } }, ...(showCampus ? { campus: { select: { name: true } } } : {}) } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
+41 -5
View File
@@ -537,6 +537,7 @@ const getUserProfile = async (req, res) => {
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
newsAskedAt: true,
noticeVersionAccepted: true
}
@@ -547,7 +548,9 @@ const getUserProfile = async (req, res) => {
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
// noticePromptPending: a privacy notice version they must accept has been published.
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
// Campus-scoped staff: the campuses they're limited to (null = every campus).
const campusScope = (await require('../utils/campusScope').scopeFor(req))?.campusIds || null;
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending, campusScope });
} else {
res.status(404);
throw new Error('User not found');
@@ -571,7 +574,18 @@ const updateUserProfile = async (req, res) => {
throw new Error('User not found');
}
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
// Home campus (only while campuses are on): an active campus, or ''/null for none.
let newHomeCampusId = user.homeCampusId;
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
if (!homeCampusId) newHomeCampusId = null;
else {
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
newHomeCampusId = campus.id;
}
}
// If a password change is requested, verify current password for safety
let newHashedPassword = undefined;
@@ -619,6 +633,7 @@ const updateUserProfile = async (req, res) => {
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
homeCampusId: newHomeCampusId,
updatedAt: new Date()
},
select: {
@@ -633,6 +648,7 @@ const updateUserProfile = async (req, res) => {
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
tokenVersion: true
}
});
@@ -769,7 +785,8 @@ const getUserById = async (req, res) => {
phoneNumber: true,
createdAt: true,
updatedAt: true,
isActive: true
isActive: true,
scopedCampuses: { select: { id: true, name: true } }
}
});
@@ -798,7 +815,7 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled, scopedCampusIds } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
@@ -834,6 +851,21 @@ const updateUser = async (req, res) => {
data.tokenVersion = { increment: 1 };
}
// Campus-scoped staff (utils/campusScope.js): "Limit to campuses" for supervisors and staff.
// Admins and users are never limited, so a role change away from staff clears the limit.
const before = await prisma.user.findUnique({ where: { id: user.id }, select: { scopedCampuses: { select: { id: true } } } });
const beforeIds = before.scopedCampuses.map(c => c.id).sort();
let afterIds = beforeIds;
if (!['supervisor', 'staff'].includes(data.role)) afterIds = [];
else if (Array.isArray(scopedCampusIds)) {
const wanted = [...new Set(scopedCampusIds.filter(x => typeof x === 'string' && x))];
const found = await prisma.campus.findMany({ where: { id: { in: wanted } }, select: { id: true } });
if (found.length !== wanted.length) { res.status(400); throw new Error('One of those campuses doesn\'t exist.'); }
afterIds = wanted.sort();
}
const scopeChanged = afterIds.join(',') !== beforeIds.join(',');
if (scopeChanged) data.scopedCampuses = { set: afterIds.map(id => ({ id })) };
const updatedUser = await prisma.user.update({
where: { id: req.params.id },
data,
@@ -847,9 +879,13 @@ const updateUser = async (req, res) => {
twoFactorEnabled: true,
createdAt: true,
updatedAt: true,
isActive: true
isActive: true,
scopedCampuses: { select: { id: true, name: true } }
}
});
if (scopeChanged) {
require('../utils/adminAudit').logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'user_campus_scope_changed', targetType: 'User', targetId: updatedUser.id, metadata: { from: beforeIds, to: afterIds }, ip: getClientIp(req) });
}
if (resetTwoFactor || data.password) {
await clearTrustedDevices(updatedUser.id);
+8 -3
View File
@@ -96,6 +96,7 @@ async function health() {
}
async function stats(now = new Date()) {
const { campusesEnabled } = require('../utils/campuses');
const since = new Date(now.getTime() - 30 * DAY);
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
@@ -112,6 +113,8 @@ async function stats(now = new Date()) {
prisma.payment.aggregate({ _sum: { amount: true } }),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
const campusesOn = await campusesEnabled().catch(() => false);
const campusCount = campusesOn ? await prisma.campus.count({ where: { archivedAt: null } }) : 0;
return {
users,
events: { active: activeEvents, upcoming: upcomingEvents, total: totalEvents },
@@ -121,7 +124,8 @@ async function stats(now = new Date()) {
// Messages aren't logged one by one, so there's nothing to count yet.
messages: null,
paymentProviders: providers,
campuses: false,
campuses: campusesOn,
campusCount,
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
};
}
@@ -129,11 +133,12 @@ async function stats(now = new Date()) {
/** What the install says about itself when registering. */
async function installInfo() {
const { getSetting } = require('../utils/settingsCache');
const [name, url, waConfigured, providers] = await Promise.all([
const [name, url, waConfigured, providers, campusesOn] = await Promise.all([
getSetting('org_name', ''),
getSetting('app_base_url', process.env.FRONTEND_URL || ''),
require('../utils/whatsapp').isConfigured().catch(() => false),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
require('../utils/campuses').campusesEnabled().catch(() => false),
]);
return {
name: name || 'Cross Events',
@@ -141,7 +146,7 @@ async function installInfo() {
version: APP_VERSION,
timezone: process.env.APP_TIMEZONE || 'Africa/Johannesburg',
server: { node: process.versions.node, postgres: await postgresVersion(), os: `${os.platform()} ${os.release()}` },
features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: false },
features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: campusesOn },
};
}
+63 -3
View File
@@ -182,6 +182,8 @@ app.get('/health', async (req, res) => {
// Use routes
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
app.use('/api/kids', require('./routes/kidsRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -517,13 +519,13 @@ app.get('/docs', async (req, res) => {
responses:[
{ status:200, desc:'Success', body:{ exists:true, hasEmail:true, hasPhone:false }},
]},
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID',
{ method:'GET', path:'/api/users/:id', auth:'admin', desc:'Get a single user by ID (with scopedCampuses)',
pathParams:{ ':id':'User UUID' },
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', isActive:true, phoneNumber:'+27821234567', notificationPreference:'email' }},
{ status:404, desc:'Not found', body:{ message:'User not found' }},
]},
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role)',
{ method:'PUT', path:'/api/users/:id', auth:'admin', desc:'Update any user (including role). scopedCampusIds limits a supervisor/staff member to those campuses (empty = every campus; audited)',
pathParams:{ ':id':'User UUID' },
request:{ body:{ role:'supervisor', isActive:true, name:'Jane Doe' }},
responses:[
@@ -925,6 +927,64 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
]},
{ title: 'Campuses', base: '/api/campuses', endpoints: [
{ method:'GET', path:'/api/campuses', auth:'Public', desc:'Whether campuses are on, and the active campuses in order (empty while off)',
responses:[{ status:200, desc:'Success', body:{ enabled:true, campuses:[{ id:'c_1', name:'Henley Campus', slug:'henley-campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall', order:0 }] }}]},
{ method:'GET', path:'/api/campuses/by-slug/:slug', auth:'Public', desc:'One active campus, for its /campus/<slug> page (404 when archived or campuses are off)' },
{ method:'GET', path:'/api/campuses/admin', auth:'admin', desc:'All campuses with archived ones, event and home-campus counts, and daily-summary recipients' },
{ method:'PUT', path:'/api/campuses/settings', auth:'admin', desc:'Turn campuses on or off (off hides campus fields and filters; nothing is deleted). Audited as campuses_toggled',
request:{ body:{ enabled:true } }},
{ method:'POST', path:'/api/campuses', auth:'admin', desc:'Add a campus (slug made from the name unless given). 409 if the slug is taken',
request:{ body:{ name:'Henley Campus', address:'1 Church St', colour:'#1d4ed8', defaultLocation:'Main hall' } }},
{ method:'PUT', path:'/api/campuses/:id', auth:'admin', desc:'Change a campus, its daily-summary recipients (staff only) and archived state',
request:{ body:{ name:'Henley Campus', notifyRecipientIds:['u_1'], archived:false } }},
{ method:'PUT', path:'/api/campuses/order', auth:'admin', desc:'Put the campuses in this order', request:{ body:{ ids:['c_2','c_1'] } }},
{ method:'DELETE', path:'/api/campuses/:id', auth:'admin', desc:'Delete a campus with no events (409 CAMPUS_HAS_EVENTS otherwise — archive it)' },
{ method:'GET', path:'/api/campuses/:id/audience', auth:'supervisor+', desc:'"Everyone at campus X" for broadcasts: home campus X, plus with ?registrants=true people registered for its events in the last 12 months' },
]},
{ title: 'Kids check-in', base: '/api/kids', endpoints: [
{ method:'GET', path:'/api/kids/me', auth:'user', desc:'My family (children, authorised adults, parents), the family QR token, live pickup codes and pages from the last 24 hours' },
{ method:'POST', path:'/api/kids/me', auth:'user', desc:'Start my family', request:{ body:{ name:'The Smith family' } } },
{ method:'PUT', path:'/api/kids/me', auth:'user', desc:'Rename my family' },
{ method:'DELETE', path:'/api/kids/me', auth:'user', desc:'Leave my family (the last parent leaving deletes it with its children)' },
{ method:'POST', path:'/api/kids/me/children', auth:'user', desc:'Add a child (allergies, medical and custody notes are stored encrypted)',
request:{ body:{ firstName:'Mia', lastName:'Smith', dateOfBirth:'2021-03-04', allergies:'Peanuts', medicalNotes:'', custodyNotes:'' } } },
{ method:'PUT', path:'/api/kids/me/children/:id', auth:'user', desc:'Change a child (custody note changes are audited)' },
{ method:'DELETE', path:'/api/kids/me/children/:id', auth:'user', desc:'Remove a child' },
{ method:'POST', path:'/api/kids/me/adults', auth:'user', desc:'Add an authorised adult (no account needed; up to 10)', request:{ body:{ name:'Mary Smith', phone:'0821234567', relationship:'Gran' } } },
{ method:'PUT', path:'/api/kids/me/adults/:id', auth:'user', desc:'Change an authorised adult' },
{ method:'DELETE', path:'/api/kids/me/adults/:id', auth:'user', desc:'Remove an authorised adult' },
{ method:'GET', path:'/api/kids/setup-events', auth:'supervisor+', desc:'Upcoming events to set kids check-in up for (campus-scoped)' },
{ method:'GET', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Kids check-in settings, the main tickets that can be the kids ticket and the allocation sets' },
{ method:'PUT', path:'/api/kids/events/:eventId/settings', auth:'supervisor+', desc:'Switch kids check-in on/off and choose the kids ticket and rooms ("new" makes a free "Kids check-in" ticket or a "Kids rooms" set). Audited as kids_settings_updated',
request:{ body:{ enabled:true, optionId:'new', roomSetId:'new' } } },
{ method:'GET', path:'/api/kids/events', auth:'staff+', desc:'Events with kids check-in from the last week on' },
{ method:'GET', path:'/api/kids/events/:eventId/board', auth:'staff+', desc:'Rooms (counts, leaders on duty, full/over ratio), check-ins with allergies, medical and custody notes, and alerts for today\'s occasion. ?log=1 writes the access log' },
{ method:'GET', path:'/api/kids/events/:eventId/families?q=', auth:'staff+', desc:'Find families by family, child or parent name, email or phone' },
{ method:'POST', path:'/api/kids/events/:eventId/families', auth:'staff+', desc:'New family at the desk: the parent\'s account is matched by email/phone or created inactive',
request:{ body:{ name:'The Smith family', parent:{ name:'Anna Smith', phone:'0821234567', email:'' }, children:[{ firstName:'Mia', dateOfBirth:'2021-03-04' }] } } },
{ method:'GET', path:'/api/kids/events/:eventId/families/qr/:token', auth:'staff+', desc:'The family for a scanned family QR code' },
{ method:'GET', path:'/api/kids/events/:eventId/families/:familyId', auth:'staff+', desc:'One family' },
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/parents', auth:'staff+', desc:'Add a parent to a family' },
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/children', auth:'staff+', desc:'Add a child (custody notes: supervisors only)' },
{ method:'PUT', path:'/api/kids/events/:eventId/families/:familyId/children/:childId', auth:'staff+', desc:'Change a child' },
{ method:'POST', path:'/api/kids/events/:eventId/families/:familyId/adults', auth:'staff+', desc:'Add an authorised adult' },
{ method:'POST', path:'/api/kids/events/:eventId/check-in', auth:'staff+', desc:'Check children in: one pickup code per family per occasion, a free place on the kids ticket the first time, a ticket usage each time. Sends the code to the parents (and optionally an authorised adult)',
request:{ body:{ familyId:'f_1', children:[{ childId:'c_1', roomId:'g_1' }], sendToAdultId:null } },
responses:[{ status:201, desc:'Checked in', body:{ code:'K7P2', qr:'KIDS-K7P2', checkIns:[{ id:'k_1', name:'Mia Smith', room:{ id:'g_1', name:'Toddlers' }, sticker:'ALLERGY: PEANUTS' }] } }] },
{ method:'GET', path:'/api/kids/events/:eventId/code/:code', auth:'staff+', desc:'The children on a pickup code (for confirming who is collected)' },
{ method:'POST', path:'/api/kids/events/:eventId/check-out', auth:'staff+', desc:'Check out with the pickup code. 404 WRONG_CODE raises a room alert (audited as kids_wrong_code)',
request:{ body:{ code:'K7P2', collectedBy:'Anna Smith', checkInIds:['k_1'] } } },
{ method:'POST', path:'/api/kids/check-ins/:id/override', auth:'staff+', desc:'Check one child out without the code; reason and who collected required. Audited as kids_checkout_override' },
{ method:'POST', path:'/api/kids/check-ins/:id/page', auth:'staff+', desc:'Ask the parents to come to the room (WhatsApp/email; shows on their Kids page)' },
{ method:'PUT', path:'/api/kids/check-ins/:id/room', auth:'staff+', desc:'Move a child to another room' },
{ method:'POST', path:'/api/kids/events/:eventId/duty', auth:'staff+', desc:'"I\'m on duty" in a room (on:false to go off), for the ratio' },
{ method:'POST', path:'/api/kids/alerts/:id/resolve', auth:'staff+', desc:'Mark an alert as dealt with' },
{ method:'GET', path:'/api/kids/events/:eventId/offline?deviceId=', auth:'staff+', desc:'Offline list for a desk: families, rooms, who is in, and 20 pickup codes reserved for the device' },
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
]},
{ title: '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:[] }}]},
@@ -1171,7 +1231,7 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion. ?campus=<id|slug|all> for one campus or church-wide events only' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
+13 -12
View File
@@ -5,18 +5,19 @@ const {
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
} = require('../controllers/allocationController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
router.get('/my-groups', protect, staff, getMyGroups);
router.get('/event/:eventId', protect, supervisor, getBoard);
router.post('/sets', protect, supervisor, createSet);
router.put('/sets/:id', protect, supervisor, updateSet);
router.delete('/sets/:id', protect, supervisor, deleteSet);
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
router.post('/sets/:id/groups', protect, supervisor, createGroup);
router.put('/sets/:id/assignments', protect, supervisor, assign);
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
router.put('/groups/:id', protect, supervisor, updateGroup);
router.delete('/groups/:id', protect, supervisor, deleteGroup);
router.get('/my-groups', protect, staff, campusIndependent('only the groups the signed-in person leads'), getMyGroups);
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getBoard);
router.post('/sets', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createSet);
router.put('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), updateSet);
router.delete('/sets/:id', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), deleteSet);
router.get('/sets/:id/lists', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), getSetLists);
router.post('/sets/:id/groups', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), createGroup);
router.put('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), assign);
router.delete('/sets/:id/assignments', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), clearAssignments);
router.post('/sets/:id/auto', protect, supervisor, scopeEvent({ resolve: 'allocationSet' }), autoAssign);
router.put('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), updateGroup);
router.delete('/groups/:id', protect, supervisor, scopeEvent({ resolve: 'allocationGroup' }), deleteGroup);
module.exports = router;
+2 -1
View File
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
const router = express.Router();
const { trackStage, getFunnel } = require('../controllers/analyticsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
const trackLimiter = rateLimit({
windowMs: 60 * 1000,
@@ -13,6 +14,6 @@ const trackLimiter = rateLimit({
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, getFunnel);
router.get('/funnel', protect, supervisor, scopeList, getFunnel);
module.exports = router;
+9 -8
View File
@@ -6,20 +6,21 @@ const {
myCertificates, downloadCertificate, verifyCertificate,
} = require('../controllers/attendanceController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent, campusIndependent } = require('../utils/campusScope');
// Certificates — fixed paths first so they aren't taken as an :eventId
router.get('/certificates/verify/:code', verifyCertificate);
router.get('/certificates/mine', protect, myCertificates);
router.post('/certificates/preview', protect, supervisor, previewCertificate);
router.post('/certificates/preview', protect, supervisor, campusIndependent('previews a certificate template from the request body'), previewCertificate);
router.get('/certificates/:id/pdf', protect, downloadCertificate);
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
router.post('/certificates/:id/revoke', protect, supervisor, scopeEvent({ resolve: 'certificate' }), revokeCertificate);
// Attendance register
router.get('/:eventId', protect, staff, getRegister);
router.get('/:eventId/export', protect, staff, exportRegister);
router.post('/:eventId/mark', protect, supervisor, markAttendance);
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
router.get('/:eventId', protect, staff, scopeEvent(), getRegister);
router.get('/:eventId/export', protect, staff, scopeEvent(), exportRegister);
router.post('/:eventId/mark', protect, supervisor, scopeEvent(), markAttendance);
router.post('/:eventId/certificates', protect, supervisor, scopeEvent(), issueCertificates);
router.post('/:eventId/certificates/send', protect, supervisor, scopeEvent(), sendCertificatesHandler);
router.get('/:eventId/certificates.pdf', protect, supervisor, scopeEvent(), downloadEventCertificates);
module.exports = router;
+2 -1
View File
@@ -2,8 +2,9 @@ const express = require('express');
const router = express.Router();
const { scheduleAutomations } = require('../controllers/automationController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// Schedule multiple automation emails for an event
router.post('/schedule', protect, supervisor, scheduleAutomations);
router.post('/schedule', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), scheduleAutomations);
module.exports = router;
+2 -1
View File
@@ -2,8 +2,9 @@ const express = require('express');
const router = express.Router();
const { getBanner, setBanner } = require('../controllers/bannerController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { unscopedOnly } = require('../utils/campusScope');
router.get('/', getBanner);
router.post('/', protect, supervisor, setBanner);
router.post('/', protect, supervisor, unscopedOnly, setBanner);
module.exports = router;
+4 -3
View File
@@ -2,10 +2,11 @@ const express = require('express');
const router = express.Router();
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeBroadcast } = require('../utils/campusScope');
// All routes require supervisor (or admin via middleware logic) access
router.post('/preview', protect, supervisor, previewBroadcast);
router.post('/send', protect, supervisor, sendBroadcast);
router.post('/schedule', protect, supervisor, scheduleBroadcast);
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), previewBroadcast);
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), sendBroadcast);
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'emails' }), scheduleBroadcast);
module.exports = router;
+5 -4
View File
@@ -2,13 +2,14 @@ const express = require('express');
const router = express.Router();
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// What a basket gets off before registering (guests included)
router.post('/quote', optionalAuth, quoteBundles);
router.get('/event/:eventId', protect, supervisor, listBundles);
router.post('/event/:eventId', protect, supervisor, createBundle);
router.put('/:id', protect, supervisor, updateBundle);
router.delete('/:id', protect, supervisor, deleteBundle);
router.get('/event/:eventId', protect, supervisor, scopeEvent(), listBundles);
router.post('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), createBundle);
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), updateBundle);
router.delete('/:id', protect, supervisor, scopeEvent({ resolve: 'bundle', need: 'manage' }), deleteBundle);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const {
getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus,
getCampusAudience,
} = require('../controllers/campusController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
// Multi-campus (utils/campuses.js). The public list is empty while campuses are off.
router.get('/', getCampuses);
router.get('/by-slug/:slug', getCampusBySlug);
router.get('/admin', protect, admin, getCampusesAdmin);
router.put('/settings', protect, admin, updateCampusSettings);
router.put('/order', protect, admin, reorderCampuses);
router.post('/', protect, admin, createCampus);
router.put('/:id', protect, admin, updateCampus);
router.delete('/:id', protect, admin, deleteCampus);
router.get('/:id/audience', protect, supervisor, scopeList, getCampusAudience);
module.exports = router;
+5 -4
View File
@@ -2,11 +2,12 @@ const express = require('express');
const router = express.Router();
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
router.get('/audit', protect, supervisor, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
router.get('/audit', protect, supervisor, scopeList, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, scopeEvent({ need: 'manage' }), getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, scopeEvent({ need: 'manage' }), savePersonCash);
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
+6 -5
View File
@@ -3,15 +3,16 @@ const rateLimit = require('express-rate-limit');
const router = express.Router();
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// The token in the link is the parent's only credential, so keep guessing slow.
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.get('/events/:eventId', protect, staff, listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
router.post('/requests/:id/resend', protect, resendRequest);
router.get('/events/:eventId', protect, staff, scopeEvent(), listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, scopeEvent(), resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, scopeEvent(), rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, scopeEvent(), medicalList);
router.post('/requests/:id/resend', protect, scopeEvent({ resolve: 'consentRequest', ownerOk: true }), resendRequest);
// Public consent page
router.get('/:token', consentLimiter, getConsent);
+2 -1
View File
@@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router();
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
router.get('/events/:eventId/costs', protect, supervisor, scopeEvent({ need: 'manage' }), getEventCosts);
router.post('/events/:eventId/costs', protect, admin, createEventCost);
router.put('/costs/:id', protect, admin, updateEventCost);
router.delete('/costs/:id', protect, admin, deleteEventCost);
+7 -6
View File
@@ -2,14 +2,15 @@ const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.post('/validate', optionalAuth, scopeEvent({ from: req => req.body?.eventId }), validateCode);
router.get('/', protect, supervisor, listCoupons);
router.post('/', protect, supervisor, createCoupon);
router.get('/redemptions', protect, supervisor, listRedemptions);
router.get('/:id', protect, supervisor, getCoupon);
router.put('/:id', protect, supervisor, updateCoupon);
router.get('/', protect, supervisor, scopeList, listCoupons);
router.post('/', protect, supervisor, scopeList, createCoupon);
router.get('/redemptions', protect, supervisor, scopeList, listRedemptions);
router.get('/:id', protect, supervisor, scopeList, getCoupon);
router.put('/:id', protect, supervisor, scopeList, updateCoupon);
module.exports = router;
+15 -14
View File
@@ -29,6 +29,7 @@ const {
getEventByAlias
} = require('../controllers/eventController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Public routes
router.get('/', getEvents);
@@ -49,40 +50,40 @@ router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
router.post('/', protect, supervisor, createEvent);
router.post('/', protect, supervisor, scopeList, createEvent);
router.route('/:id')
.put(protect, supervisor, updateEvent)
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
.delete(protect, admin, deleteEvent);
// Notification recipients: who gets registration/payment/daily-summary emails for this event
router.route('/:id/notify-recipients')
.get(protect, supervisor, getEventNotifyRecipients)
.put(protect, supervisor, updateEventNotifyRecipients);
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEventNotifyRecipients);
// Attachments routes
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
router.post('/:id/attachments', protect, supervisor, scopeEvent({ need: 'manage' }), ...uploadEventAttachment);
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventAttachment);
// Bulk email attendees
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
router.post('/:id/email-attendees', protect, supervisor, scopeEvent(), emailEventAttendees);
// Schedule bulk email to attendees
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
router.post('/:id/email-attendees/schedule', protect, supervisor, scopeEvent(), scheduleEmailEventAttendees);
// Bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
router.post('/:id/whatsapp-attendees', protect, supervisor, scopeEvent(), whatsappEventAttendees);
// Schedule bulk WhatsApp message to attendees
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scopeEvent(), scheduleWhatsappEventAttendees);
// Event options routes
router.post('/:id/options', protect, supervisor, createEventOption);
router.post('/:id/options', protect, supervisor, scopeEvent({ need: 'manage' }), createEventOption);
router.route('/options/:id')
.put(protect, supervisor, updateEventOption)
.put(protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), updateEventOption)
.delete(protect, admin, deleteEventOption);
// Option variant routes
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
router.post('/options/:id/variants', protect, supervisor, scopeEvent({ resolve: 'eventOption', need: 'manage' }), createOptionVariant);
router.route('/variants/:id')
.put(protect, supervisor, updateOptionVariant)
.put(protect, supervisor, scopeEvent({ resolve: 'optionVariant', need: 'manage' }), updateOptionVariant)
.delete(protect, admin, deleteOptionVariant);
module.exports = router;
+6 -5
View File
@@ -2,13 +2,14 @@ const express = require('express');
const router = express.Router();
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
router.get('/public/:token', getPublicForm);
router.post('/public/:token', submitPublicForm);
router.get('/summary', protect, supervisor, getSummary);
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
router.get('/summary', protect, supervisor, scopeList, getSummary);
router.get('/event/:eventId', protect, supervisor, scopeEvent(), getEventFeedback);
router.put('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), saveEventFeedback);
router.delete('/event/:eventId', protect, supervisor, scopeEvent({ need: 'manage' }), deleteEventFeedback);
router.post('/event/:eventId/send-now', protect, supervisor, scopeEvent({ need: 'manage' }), sendNow);
module.exports = router;
+2 -1
View File
@@ -2,8 +2,9 @@ const express = require('express');
const router = express.Router();
const { listFormResponses } = require('../controllers/formController');
const { protect, staff } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
// Staff or higher can view submitted forms
router.get('/responses', protect, staff, listFormResponses);
router.get('/responses', protect, staff, scopeList, listFormResponses);
module.exports = router;
+47
View File
@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const c = require('../controllers/kidsController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// My account → Kids (any signed-in parent; only their own family)
router.get('/me', protect, c.getMine);
router.post('/me', protect, c.createMine);
router.put('/me', protect, c.renameMine);
router.delete('/me', protect, c.leaveMine);
router.post('/me/children', protect, c.saveMyChild);
router.put('/me/children/:id', protect, c.saveMyChild);
router.delete('/me/children/:id', protect, c.deleteMyChild);
router.post('/me/adults', protect, c.saveMyAdult);
router.put('/me/adults/:id', protect, c.saveMyAdult);
router.delete('/me/adults/:id', protect, c.deleteMyAdult);
// Setup (supervisors)
router.get('/setup-events', protect, supervisor, scopeList, c.setupEvents);
router.get('/events/:eventId/settings', protect, supervisor, scopeEvent(), c.getSettings);
router.put('/events/:eventId/settings', protect, supervisor, scopeEvent({ need: 'manage' }), c.saveSettings);
// The desk and the rooms (staff). Families are church-wide; staff reach them through an event
// they can work on.
router.get('/events', protect, staff, scopeList, c.listEvents);
router.get('/events/:eventId/board', protect, staff, scopeEvent(), c.getBoard);
router.get('/events/:eventId/families', protect, staff, scopeEvent(), c.searchFamilies);
router.post('/events/:eventId/families', protect, staff, scopeEvent(), c.createFamilyAtDesk);
router.get('/events/:eventId/families/qr/:token', protect, staff, scopeEvent(), c.familyByQr);
router.get('/events/:eventId/families/:familyId', protect, staff, scopeEvent(), c.getFamily);
router.post('/events/:eventId/families/:familyId/parents', protect, staff, scopeEvent(), c.addParentAtDesk);
router.post('/events/:eventId/families/:familyId/children', protect, staff, scopeEvent(), c.saveChildAtDesk);
router.put('/events/:eventId/families/:familyId/children/:childId', protect, staff, scopeEvent(), c.saveChildAtDesk);
router.post('/events/:eventId/families/:familyId/adults', protect, staff, scopeEvent(), c.addAdultAtDesk);
router.post('/events/:eventId/check-in', protect, staff, scopeEvent(), c.checkIn);
router.post('/events/:eventId/check-out', protect, staff, scopeEvent(), c.checkOut);
router.get('/events/:eventId/code/:code', protect, staff, scopeEvent(), c.lookupCode);
router.post('/events/:eventId/duty', protect, staff, scopeEvent(), c.setDuty);
router.get('/events/:eventId/offline', protect, staff, scopeEvent(), c.offlineSnapshot);
router.post('/events/:eventId/sync', protect, staff, scopeEvent(), c.syncOffline);
router.post('/check-ins/:id/override', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.overrideCheckOut);
router.post('/check-ins/:id/page', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.pageParent);
router.put('/check-ins/:id/room', protect, staff, scopeEvent({ resolve: 'kidsCheckIn' }), c.moveRoom);
router.post('/alerts/:id/resolve', protect, staff, scopeEvent({ resolve: 'kidsAlert' }), c.resolveAlert);
module.exports = router;
+19 -13
View File
@@ -17,32 +17,38 @@ const {
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList, unscopedOnly } = require('../utils/campusScope');
// Campus-scoped staff: payments follow their registration's or event's campus. Door payments for
// church-wide events are allowed; refunds of them aren't (need 'manage'); a payment or donation
// not linked to any event is for admins and unscoped supervisors (orphan 'deny').
const paymentTarget = scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
// Protected routes
router.post('/', protect, supervisor, createPayment);
router.post('/', protect, supervisor, paymentTarget, createPayment);
router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider);
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
router.get('/checkouts/open', protect, supervisor, unscopedOnly, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, unscopedOnly, checkCheckoutStatus);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout);
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
router.post('/checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
// Older names, kept for existing clients
router.post('/yoco-checkout', protect, createCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.post('/yoco-checkout/send', protect, supervisor, scopeBody([['registrationId', 'registration']]), sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.post('/:id/send-receipt', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), sendReceipt);
router.get('/:id', protect, scopeEvent({ resolve: 'payment', ownerOk: true }), getPaymentById);
router.get('/registration/:registrationId', protect, scopeEvent({ resolve: 'registration', from: req => req.params.registrationId, ownerOk: true }), getPaymentsByRegistration);
// Admin/Staff routes
router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund);
router.get('/', protect, supervisor, scopeList, getPayments);
router.get('/event/:eventId', protect, staff, scopeEvent(), getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), scopeBody([['registrationId', 'registration']]), assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, scopeBody([['legId', 'payment']], { orphan: 'deny' }), unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' }), createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats);
module.exports = router;
@@ -2,19 +2,20 @@ const express = require('express');
const router = express.Router();
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
const { protect, supervisor} = require('../middleware/authMiddleware');
const { unscopedOnly } = require('../utils/campusScope');
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
// GET all payment provider transactions (admin/supervisor)
router.get('/', protect, supervisor, getAllProviderTransactions);
router.get('/', protect, supervisor, unscopedOnly, getAllProviderTransactions);
// GET all unreconciled transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
router.get('/unreconciled', protect, supervisor, unscopedOnly, getUnreconciledProviderTransactions);
// POST reconcile a transaction
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
router.post('/:id/reconcile', protect, supervisor, unscopedOnly, reconcileProviderTransaction);
// POST ignore a transaction
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
router.post('/:id/ignore', protect, supervisor, unscopedOnly, ignoreProviderTransaction);
module.exports = router;
+20 -16
View File
@@ -17,29 +17,33 @@ const {
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
// Registration - auth optional when event doesn't require it
router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
router.post('/:id/coupon', protect, applyCoupon);
router.delete('/:id/coupon', protect, staff, removeCoupon);
router.put('/:id/attendees', protect, updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
router.get('/:id', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getRegistrationById);
router.get('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), getFormDraft);
router.put('/:id/forms/draft', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), saveFormDraft);
router.post('/:id/forms/responses', optionalAuth, scopeEvent({ resolve: 'registration', ownerOk: true }), submitFormResponses);
router.put('/:id/forms/responses', protect, supervisor, scopeEvent({ resolve: 'registration' }), replaceFormResponses);
// Admin/Staff routes
router.get('/', protect, staff, getRegistrations);
router.put('/:id', protect, staff, updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
router.post('/manual', protect, supervisor, createManualRegistration);
router.get('/', protect, staff, scopeList, getRegistrations);
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
module.exports = router;
+9 -8
View File
@@ -2,14 +2,15 @@ const express = require('express');
const router = express.Router();
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
router.get('/', protect, staff, listLocks);
router.post('/', protect, staff, createLock);
router.get('/reports', protect, supervisor, listReports);
router.post('/reports/:id/review', protect, supervisor, reviewReport);
router.get('/:id/snapshot', protect, staff, getSnapshot);
router.post('/:id/sync', protect, staff, syncLock);
router.post('/:id/force-release', protect, supervisor, forceRelease);
router.delete('/:id', protect, staff, releaseLock);
router.get('/', protect, staff, scopeEvent({ from: req => req.query.eventId }), listLocks);
router.post('/', protect, staff, scopeEvent({ from: req => req.body?.eventId }), createLock);
router.get('/reports', protect, supervisor, scopeEvent({ from: req => req.query.eventId }), listReports);
router.post('/reports/:id/review', protect, supervisor, scopeEvent({ resolve: 'syncReport' }), reviewReport);
router.get('/:id/snapshot', protect, staff, scopeEvent({ resolve: 'scanLock' }), getSnapshot);
router.post('/:id/sync', protect, staff, scopeEvent({ resolve: 'scanLock' }), syncLock);
router.post('/:id/force-release', protect, supervisor, scopeEvent({ resolve: 'scanLock' }), forceRelease);
router.delete('/:id', protect, staff, scopeEvent({ resolve: 'scanLock' }), releaseLock);
module.exports = router;
+4 -3
View File
@@ -2,10 +2,11 @@ const express = require('express');
const router = express.Router();
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
// All routes require supervisor (or admin via middleware logic) access
router.get('/', protect, supervisor, listScheduledEmails);
router.patch('/:id', protect, supervisor, updateScheduledEmail);
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
router.get('/', protect, supervisor, scopeList, listScheduledEmails);
router.patch('/:id', protect, supervisor, scopeList, updateScheduledEmail);
router.delete('/:id', protect, supervisor, scopeList, deleteScheduledEmail);
module.exports = router;
+4 -3
View File
@@ -7,10 +7,11 @@ const {
updateSection
} = require('../controllers/sectionController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
router.get('/', protect, staff, getSections);
router.post('/', protect, supervisor, createSection);
router.get('/', protect, staff, scopeList, getSections);
router.post('/', protect, supervisor, scopeEvent({ from: req => req.body?.eventId, need: 'manage' }), createSection);
router.delete('/:id', protect, admin, deleteSection);
router.put('/:id', protect, supervisor, updateSection);
router.put('/:id', protect, supervisor, scopeEvent({ resolve: 'section', need: 'manage' }), updateSection);
module.exports = router;
+2 -1
View File
@@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router();
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
router.get('/event/:eventId', getEventSponsorship);
router.get('/event/:eventId/pool', protect, supervisor, getPool);
router.get('/event/:eventId/pool', protect, supervisor, scopeEvent(), getPool);
router.post('/checkout', optionalAuth, createSponsorCheckout);
module.exports = router;
+4 -3
View File
@@ -2,16 +2,17 @@ const express = require('express');
const router = express.Router();
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
const { scopeList } = require('../utils/campusScope');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
// single response instead of the dashboard making several separate calls (and, previously,
// pulling full payments/events lists just to reduce them to a couple of numbers).
router.get('/staff', protect, staff, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
router.get('/staff', protect, staff, scopeList, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, scopeList, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
// Staff never sees financial data, so this has no staff-accessible route.
router.get('/overview', protect, supervisor, getOverviewStats);
router.get('/overview', protect, supervisor, scopeList, getOverviewStats);
module.exports = router;
+16 -13
View File
@@ -22,31 +22,34 @@ const {
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Campus-scoped staff (utils/campusScope.js). The scanner names the campus of another campus's ticket.
// Protected routes
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.get('/:id', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
router.post('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), transferTicketHandler);
router.delete('/:id/transfer', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
router.get('/:id/transfers', protect, scopeEvent({ resolve: 'ticket', ownerOk: true }), getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, sendTicketsTo);
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
router.post('/scan/:qrCode', protect, staff, scanTicket);
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
router.get('/scans/recent', protect, staff, getRecentScans);
router.get('/scans/stats', protect, staff, getScanStats);
router.post('/send-to', protect, staff, scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]), sendTicketsTo);
router.get('/scan-preview/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getScanPreview);
router.get('/qr/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), getTicketByQrCode);
router.post('/scan/:qrCode', protect, staff, scopeEvent({ resolve: 'ticketQr', from: req => req.params.qrCode, nameCampus: true }), scanTicket);
router.get('/event/:eventId', protect, staff, scopeEvent(), getTicketsByEvent);
router.get('/scans/recent', protect, staff, scopeList, getRecentScans);
router.get('/scans/stats', protect, staff, scopeList, getScanStats);
// Admin routes
router.post('/generate', protect, supervisor, generateTickets);
router.get('/', protect, supervisor, getTickets);
router.post('/generate', protect, supervisor, scopeBody([['registrationId', 'registration']]), generateTickets);
router.get('/', protect, supervisor, scopeList, getTickets);
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
module.exports = router;
+2 -1
View File
@@ -3,12 +3,13 @@ const router = express.Router();
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const prisma = require('../config/db');
// @route POST /api/uploads/event-image
// @desc Upload an image for an event
// @access Private (Supervisor+ recommended)
router.post('/event-image', protect, supervisor, (req, res, next) => {
router.post('/event-image', protect, supervisor, campusIndependent('stores an image file; the event it goes on is checked when the event is saved'), (req, res, next) => {
upload.single('image')(req, res, (err) => {
if (err) {
req.multerError = err;
+3 -2
View File
@@ -26,6 +26,7 @@ const {
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Public routes
router.post('/', registerUser);
@@ -51,9 +52,9 @@ router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
// Admin routes
router.route('/')
.get(protect, supervisor, getUsers);
.get(protect, supervisor, campusIndependent('accounts are shared across campuses'), getUsers);
router.get('/check-exists', protect, supervisor, checkUserExists);
router.get('/check-exists', protect, supervisor, campusIndependent('accounts are shared across campuses'), checkUserExists);
router.route('/:id')
.get(protect, admin, getUserById)
+5 -4
View File
@@ -3,6 +3,7 @@ const rateLimit = require('express-rate-limit');
const router = express.Router();
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent } = require('../utils/campusScope');
// Claim links are credentials, so keep guessing slow.
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
@@ -10,10 +11,10 @@ const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { messag
router.post('/', optionalAuth, joinWaitlist);
router.get('/mine', protect, myWaitlist);
router.get('/claim/:token', claimLimiter, claimInfo);
router.get('/events/:eventId', protect, staff, eventWaitlist);
router.post('/:id/offer', protect, supervisor, offerManually);
router.post('/:id/move', protect, supervisor, moveEntry);
router.post('/:id/remove', protect, supervisor, removeEntry);
router.get('/events/:eventId', protect, staff, scopeEvent(), eventWaitlist);
router.post('/:id/offer', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), offerManually);
router.post('/:id/move', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), moveEntry);
router.post('/:id/remove', protect, supervisor, scopeEvent({ resolve: 'waitlistEntry' }), removeEntry);
router.delete('/:id', protect, leaveWaitlist);
module.exports = router;
@@ -2,9 +2,10 @@ const express = require('express');
const router = express.Router();
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { scopeBroadcast } = require('../utils/campusScope');
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
router.post('/preview', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), previewWhatsAppBroadcast);
router.post('/send', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), sendWhatsAppBroadcast);
router.post('/schedule', protect, supervisor, scopeBroadcast({ freeformField: 'phones' }), scheduleWhatsAppBroadcast);
module.exports = router;
+2
View File
@@ -15,6 +15,8 @@ const ACCESS_ACTIONS = {
medical_list: 'Medical list viewed',
backup_download: 'Database backup downloaded',
impersonation: 'Viewed the site as another user',
kids_rooms: 'Kids check-in desk or room opened',
kids_offline: 'Kids check-in list downloaded for offline use',
};
/**
+288
View File
@@ -0,0 +1,288 @@
/**
* Campus-scoped staff (1.44.0). A supervisor or staff member can be limited to some campuses
* (Admin → Users → "Limit to campuses"). They then see their campuses' events plus events for
* all campuses (Event.campusId null) — and can work on those church-wide events (view, scan,
* door payments, manual registrations) but not set them up, cash them up or refund them.
*
* Unscoped (no limits) = everything, as before. Admins are never scoped, and scopes are ignored
* while campuses are off (Site settings → Campuses).
*
* How a route is checked — every staff/supervisor route carries one of these markers, and
* tests/campusScopeRoutes.test.js fails on any route that doesn't:
* scopeEvent({ resolve, from, need }) the route works on one event (or a record of one)
* scopeList the controller filters (listWhere) or checks it itself
* unscopedOnly admins and unscoped supervisors only
* campusIndependent('reason') nothing to do with any event's campus
*/
const prisma = require('../config/db');
const { campusesEnabled } = require('./campuses');
const STAFF_ROLES = new Set(['supervisor', 'staff']);
/**
* The scope of the signed-in user: null (unscoped) or { campusIds, names }. Worked out once
* per request.
*/
async function scopeFor(req) {
if (req._campusScope !== undefined) return req._campusScope;
let scope = null;
if (req.user && STAFF_ROLES.has(req.user.role) && await campusesEnabled()) {
const row = await prisma.user.findUnique({ where: { id: req.user.id }, select: { scopedCampuses: { select: { id: true, name: true } } } });
const campuses = row?.scopedCampuses || [];
if (campuses.length) scope = { campusIds: campuses.map(c => c.id), names: campuses.map(c => c.name) };
}
req._campusScope = scope;
return scope;
}
/** The `where` on events for a scope: its campuses plus church-wide events. {} when unscoped. */
function eventWhere(scope) {
if (!scope) return {};
return { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] };
}
/** The `where` on events a scope may manage: only its campuses' (not church-wide). {} when unscoped. */
function manageWhere(scope) {
if (!scope) return {};
return { campusId: { in: scope.campusIds } };
}
/**
* The same, through a relation path: relWhere(scope, 'event') → { event: {...} },
* relWhere(scope, 'registration.event') → { registration: { event: {...} } }.
*/
function relWhere(scope, path) {
if (!scope) return {};
return path.split('.').reverse().reduce((inner, key) => ({ [key]: inner }), eventWhere(scope));
}
/** Can this scope see/work on an event at `campusId` (null = all campuses)? */
function canView(scope, campusId) {
return !scope || campusId == null || scope.campusIds.includes(campusId);
}
/** Can this scope set up, cash up or refund an event at `campusId`? Not church-wide ones. */
function canManage(scope, campusId) {
return !scope || (campusId != null && scope.campusIds.includes(campusId));
}
// id → { id, campusId, campusName } of the event a record belongs to, or null if there's no such
// record. The event select is shared so every resolver answers the same shape.
// `owners` are the users the record belongs to, for scopeEvent({ ownerOk }).
const EV = { select: { id: true, campusId: true, campus: { select: { name: true } } } };
const shape = (ev, owners = []) => (ev ? { id: ev.id, campusId: ev.campusId, campusName: ev.campus?.name || null, owners: owners.filter(Boolean) } : null);
const RESOLVERS = {
event: async id => shape(await prisma.event.findUnique({ where: { id }, ...EV })),
registration: async id => {
const r = await prisma.registration.findUnique({ where: { id }, select: { userId: true, event: EV } });
return shape(r?.event, [r?.userId]);
},
ticket: async id => {
const t = await prisma.ticket.findUnique({ where: { id }, select: { userId: true, holderUserId: true, event: EV } });
return shape(t?.event, [t?.userId, t?.holderUserId]);
},
ticketQr: async qrCode => shape((await prisma.ticket.findUnique({ where: { qrCode }, select: { event: EV } }))?.event),
payment: async id => {
const p = await prisma.payment.findUnique({ where: { id }, select: { userId: true, event: EV, registration: { select: { event: EV } } } });
return shape(p?.event || p?.registration?.event, [p?.userId]);
},
eventOption: async id => shape((await prisma.eventOption.findUnique({ where: { id }, select: { event: EV } }))?.event),
optionVariant: async id => shape((await prisma.optionVariant.findUnique({ where: { id }, select: { eventOption: { select: { event: EV } } } }))?.eventOption?.event),
bundle: async id => shape((await prisma.eventBundle.findUnique({ where: { id }, select: { event: EV } }))?.event),
allocationSet: async id => shape((await prisma.allocationSet.findUnique({ where: { id }, select: { event: EV } }))?.event),
allocationGroup: async id => shape((await prisma.allocationGroup.findUnique({ where: { id }, select: { set: { select: { event: EV } } } }))?.set?.event),
waitlistEntry: async id => shape((await prisma.waitlistEntry.findUnique({ where: { id }, select: { event: EV } }))?.event),
scanLock: async id => shape((await prisma.scanLock.findUnique({ where: { id }, select: { event: EV } }))?.event),
syncReport: async id => shape((await prisma.offlineSyncReport.findUnique({ where: { id }, select: { scanLock: { select: { event: EV } } } }))?.scanLock?.event),
section: async id => shape((await prisma.section.findUnique({ where: { id }, select: { event: EV } }))?.event),
certificate: async id => shape((await prisma.certificate.findUnique({ where: { id }, select: { event: EV } }))?.event),
consentRequest: async id => {
const c = await prisma.consentRequest.findUnique({ where: { id }, select: { event: EV, attendee: { select: { registration: { select: { userId: true } } } } } });
return shape(c?.event, [c?.attendee?.registration?.userId]);
},
kidsCheckIn: async id => shape((await prisma.kidsCheckIn.findUnique({ where: { id }, select: { event: EV } }))?.event),
kidsAlert: async id => shape((await prisma.kidsAlert.findUnique({ where: { id }, select: { event: EV } }))?.event),
};
/** The event a record belongs to (see RESOLVERS), or null. */
async function eventOf(kind, id) {
const resolve = RESOLVERS[kind];
if (!resolve) throw new Error(`No campus resolver for "${kind}"`);
if (!id) return null;
return resolve(String(id));
}
const NOT_FOUND = { message: 'Not found' };
const CHURCH_WIDE_MSG = 'This is an event for all campuses — only admins and supervisors for every campus can change it.';
/**
* Check a scoped user against one event, found from the request. Out of scope → 404 (another
* campus's records don't leak); a church-wide event when `need: 'manage'` → 403. Records that
* don't exist pass through so the controller answers as it always has.
* resolve which RESOLVERS entry turns the id into its event (default 'event')
* from req → id (default req.params.eventId || req.params.id)
* need 'view' (default) or 'manage'
* ownerOk let the record's own user through (a staff member's own registration for another
* campus's event) — for routes open to the owner as well as to staff
* nameCampus answer 403 naming the event's campus instead of 404 (the ticket scanner: the
* person at the door needs to know where the ticket belongs)
*/
function scopeEvent({ resolve = 'event', from = req => req.params.eventId || req.params.id, need = 'view', ownerOk = false, nameCampus = false } = {}) {
if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
const ev = await eventOf(resolve, from(req));
if (!ev) return next();
if (ownerOk && ev.owners.includes(req.user.id)) return next();
if (!canView(scope, ev.campusId)) {
if (nameCampus) return res.status(403).json({ code: 'OTHER_CAMPUS', message: `This ticket is for an event at ${ev.campusName || 'another campus'}.` });
return res.status(404).json(NOT_FOUND);
}
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
req.scopedEvent = ev;
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = `event:${resolve}:${need}${ownerOk ? ':owner-ok' : ''}`;
return mw;
}
/**
* Like scopeEvent, for records named in the request body: `targets` is [[field, resolver], …]
* (e.g. [['registrationId', 'registration'], ['eventId', 'event']]). Every target that's given
* is checked. With `orphan: 'deny'`, a scoped user is refused when none of them leads to an
* event (a donation not linked to any event is for admins and unscoped supervisors only).
*/
function scopeBody(targets, { need = 'view', orphan = 'pass' } = {}) {
for (const [, resolve] of targets) if (!RESOLVERS[resolve]) throw new Error(`No campus resolver for "${resolve}"`);
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
let found = false;
for (const [field, resolve] of targets) {
const value = req.body?.[field];
// A list (e.g. ticketIds) checks every id in it.
for (const id of (Array.isArray(value) ? value : [value]).filter(Boolean).slice(0, 500)) {
const ev = await eventOf(resolve, id);
if (!ev) continue;
found = true;
if (!canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
if (need === 'manage' && !canManage(scope, ev.campusId)) return res.status(403).json({ code: 'CHURCH_WIDE_EVENT', message: CHURCH_WIDE_MSG });
}
}
if (!found && orphan === 'deny') {
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: "Only admins and supervisors for every campus can work with payments that aren't for an event." });
}
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = `body:${targets.map(t => t.join('→')).join(',')}:${need}:${orphan}`;
return mw;
}
/** Marker: the controller filters what it lists with listWhere(). */
const scopeList = (req, res, next) => next();
scopeList.campusScope = 'list';
/** Admins and unscoped supervisors/staff only. */
const unscopedOnly = async (req, res, next) => {
try {
if (await scopeFor(req)) return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Only admins and supervisors for every campus can do this.' });
return next();
} catch (e) {
return next(e);
}
};
unscopedOnly.campusScope = 'unscoped-only';
/** Marker for a route that has nothing to do with any event's campus; the reason is required. */
function campusIndependent(reason) {
if (!reason) throw new Error('campusIndependent needs a reason');
const mw = (req, res, next) => next();
mw.campusScope = `independent: ${reason}`;
return mw;
}
/**
* Combine a controller's `where` with a scope `where` without either overwriting the other's
* OR/AND (spreading would): { AND: [where, extra] }, or `where` itself when there's no scope.
*/
function withScope(where, extra) {
if (!extra || !Object.keys(extra).length) return where;
if (!where || !Object.keys(where).length) return extra;
return { AND: [where, extra] };
}
/**
* Broadcasts (email/WhatsApp, send/preview/schedule): a scoped supervisor may only message
* people at their campuses (home campus) or attendees of events they can see, and can't add
* free-typed addresses (`freeformField`: 'emails' or 'phones'). The placeholder event must be
* one they can see.
*/
function scopeBroadcast({ freeformField }) {
const mw = async (req, res, next) => {
try {
const scope = await scopeFor(req);
if (!scope) return next();
const body = req.body || {};
if (String(body[freeformField] || '').trim()) {
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: 'Supervisors for some campuses can only message people from the list, not typed-in addresses.' });
}
if (body.eventId) {
const ev = await eventOf('event', body.eventId);
if (ev && !canView(scope, ev.campusId)) return res.status(404).json(NOT_FOUND);
}
const ids = Array.isArray(body.userIds) ? [...new Set(body.userIds.filter(x => typeof x === 'string' && x))] : [];
if (ids.length) {
const allowed = await prisma.user.count({
where: {
id: { in: ids },
OR: [
{ homeCampusId: { in: scope.campusIds } },
{ registrations: { some: { event: eventWhere(scope) } } },
],
},
});
if (allowed !== ids.length) {
const n = ids.length - allowed;
return res.status(403).json({ code: 'CAMPUS_SCOPED', message: `${n} of the people selected ${n === 1 ? "isn't" : "aren't"} at your campuses or registered for your events.` });
}
}
return next();
} catch (e) {
return next(e);
}
};
mw.campusScope = 'broadcast';
return mw;
}
/**
* Coupons: a scoped supervisor may see and change a coupon only when it's limited to events,
* all at their campuses (a coupon for every event, or a church-wide event, is unscoped-only).
* `campusIds` are the campuses of the coupon's events.
*/
function couponAllowed(scope, campusIds) {
if (!scope) return true;
return campusIds.length > 0 && campusIds.every(id => id != null && scope.campusIds.includes(id));
}
/** The `where` for a list endpoint: relWhere through `path` ('' = the event itself). */
async function listWhere(req, path = '') {
const scope = await scopeFor(req);
return path ? relWhere(scope, path) : eventWhere(scope);
}
module.exports = {
scopeFor, eventWhere, manageWhere, relWhere, canView, canManage, eventOf, RESOLVERS,
scopeEvent, scopeBody, scopeList, unscopedOnly, campusIndependent, scopeBroadcast, couponAllowed, listWhere, withScope, CHURCH_WIDE_MSG,
};
+138
View File
@@ -0,0 +1,138 @@
/**
* Multi-campus (Site settings → Campuses, off by default). Events belong to one campus, or to
* all campuses when Event.campusId is null. While campuses are off, campus fields and filters
* are ignored everywhere (the data stays, hidden). Campus-scoped staff come in 1.44.0.
*/
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const SETTING_KEY = 'campuses_enabled';
// What the public site may see of a campus.
const CAMPUS_PUBLIC_SELECT = { id: true, name: true, slug: true, address: true, colour: true, logoUrl: true, defaultLocation: true, contactName: true, contactEmail: true, contactPhone: true, order: true };
// What an event carries about its campus (cards, tickets, lists).
const EVENT_CAMPUS_SELECT = { id: true, name: true, slug: true, colour: true };
async function campusesEnabled() {
return String(await getSetting(SETTING_KEY, 'false')).toLowerCase() === 'true';
}
/** "Hope Centre — Henley" → "hope-centre-henley". */
function slugify(text) {
return String(text || '')
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
const trimOrNull = (v, max = 200) => {
if (v === undefined) return undefined;
const s = String(v ?? '').trim();
return s ? s.slice(0, max) : null;
};
/**
* Check and clean the fields of a campus from a request body. Returns { data } or { error }.
* `partial` (an update) only touches the fields that were sent.
*/
function cleanCampusInput(body = {}, { partial = false } = {}) {
const data = {};
if (!partial || body.name !== undefined) {
const name = trimOrNull(body.name, 100);
if (!name) return { error: 'Give the campus a name.' };
data.name = name;
}
if (body.slug !== undefined || (!partial && data.name)) {
const slug = slugify(body.slug || data.name);
if (!slug) return { error: 'The web address needs letters or numbers.' };
data.slug = slug;
}
for (const [key, max] of [['address', 300], ['contactName', 100], ['contactPhone', 40], ['defaultLocation', 300], ['logoUrl', 500]]) {
const v = trimOrNull(body[key], max);
if (v !== undefined) data[key] = v;
}
if (body.contactEmail !== undefined) {
const email = trimOrNull(body.contactEmail, 200);
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'That contact email doesn\'t look right.' };
data.contactEmail = email;
}
if (body.colour !== undefined) {
const colour = trimOrNull(body.colour, 7);
if (colour && !/^#[0-9a-fA-F]{6}$/.test(colour)) return { error: 'Use a colour like #1d4ed8.' };
data.colour = colour ? colour.toLowerCase() : null;
}
return { data };
}
/**
* The `where` for a campus filter on a list of events.
* filter ''/undefined → no filter (church-wide)
* filter 'all' → only events for all campuses
* filter <campus id> → that campus's events; `withAllCampuses` also includes events for all
* campuses (the public list: a church-wide camp shows at every campus)
* Returns {} when campuses are off, so callers can spread it unconditionally.
*/
function campusWhere(filter, { enabled, withAllCampuses = false } = {}) {
if (!enabled || !filter) return {};
if (filter === 'all') return { campusId: null };
return withAllCampuses ? { OR: [{ campusId: String(filter) }, { campusId: null }] } : { campusId: String(filter) };
}
/** A campus by id or slug (archived ones too), or null. */
async function findCampus(idOrSlug) {
if (!idOrSlug) return null;
const v = String(idOrSlug);
return prisma.campus.findFirst({ where: { OR: [{ id: v }, { slug: v }] } });
}
/** Resolve a filter value from a query string (id, slug or 'all') to what campusWhere takes. */
async function resolveCampusFilter(value) {
if (!value) return '';
if (value === 'all') return 'all';
const campus = await findCampus(value);
// An unknown campus matches nothing rather than silently showing everything.
return campus ? campus.id : '__none__';
}
/**
* The campus to save on an event from a request's `campusId` (''/null = all campuses).
* Returns { campusId } (undefined = leave as is) or { error }. Ignored while campuses are off.
* An archived campus can stay on an event that already has it, but can't be newly chosen.
*/
async function campusForEvent(value, { enabled, currentCampusId = null } = {}) {
if (!enabled || value === undefined) return { campusId: undefined };
if (value === null || value === '' || value === 'all') return { campusId: null };
const campus = await prisma.campus.findUnique({ where: { id: String(value) } });
if (!campus) return { error: 'That campus doesn\'t exist.' };
if (campus.archivedAt && campus.id !== currentCampusId) return { error: `${campus.name} is archived — choose another campus.` };
return { campusId: campus.id };
}
const AUDIENCE_MONTHS = 12;
/**
* The `where` for "everyone at campus X" (broadcasts): people whose home campus is X, plus —
* with `includeRegistrants` — people with a registration for one of X's events in the last
* 12 months. Active real accounts only. News consent is still checked when sending.
*/
function campusAudienceWhere(campusId, { includeRegistrants = false, now = new Date() } = {}) {
const since = new Date(now);
since.setMonth(since.getMonth() - AUDIENCE_MONTHS);
const who = [{ homeCampusId: campusId }];
if (includeRegistrants) {
who.push({ registrations: { some: { createdAt: { gte: since }, status: { not: 'cancelled' }, event: { campusId } } } });
}
return {
isActive: true,
NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }],
OR: who,
};
}
module.exports = {
SETTING_KEY, AUDIENCE_MONTHS, CAMPUS_PUBLIC_SELECT, EVENT_CAMPUS_SELECT,
campusesEnabled, slugify, cleanCampusInput, campusWhere, findCampus, resolveCampusFilter, campusForEvent, campusAudienceWhere,
};
+841
View File
@@ -0,0 +1,841 @@
/**
* Kids check-in (1.45.0). Children are checked in at a staff desk, fully digitally:
*
* - Families (parent accounts, children, authorised adults) are kept between events.
* - A check-in gives the family a pickup code for the occasion — a session, or the day on a
* single-date event — shared by the children checked in together. The parent sees it on
* their phone (My account → Kids) and gets it by WhatsApp/email; anyone with the code can
* collect. The desk shows a "write on the sticker" panel instead of printing labels.
* - Each child gets a free place on the event's kids option the first time (registration,
* attendee, ticket under the parent), and every check-in writes a TicketUsage — so the
* attendance register, reports and campus scoping treat them like any other attendee.
* - Rooms are the groups of the event's "kids rooms" allocation set, with an age range,
* capacity and a ratio (children per leader on duty).
*
* Allergies, medical and custody notes are encrypted and never put in messages.
*/
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
const { encrypt, decrypt } = require('./encryption');
const sessionsUtil = require('./sessions');
// No 0/O or 1/I, so a code read out or copied onto a sticker can't be misread.
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 4;
const OFFLINE_CODES = 20;
const MAX_TEXT = 1000;
// What a pickup QR holds: the prefix and the code.
const QR_PREFIX = 'KIDS-';
function kidsError(message, statusCode = 400, code = undefined) {
return Object.assign(new Error(message), { statusCode, ...(code ? { code } : {}) });
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
function randomCode(length = CODE_LENGTH) {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
return out;
}
/** A typed or scanned code, cleaned up: "kids-k7p2" / " k7 p2 " → "K7P2"; null if it can't be one. */
function normaliseCode(raw) {
let s = String(raw || '').trim().toUpperCase();
if (s.startsWith(QR_PREFIX)) s = s.slice(QR_PREFIX.length);
s = s.replace(/[\s-]/g, '');
if (s.length !== CODE_LENGTH || [...s].some(c => !CODE_ALPHABET.includes(c))) return null;
return s;
}
/** Age in whole years on `on` (null without a date of birth). */
function ageOn(dateOfBirth, on = new Date()) {
if (!dateOfBirth) return null;
const d = new Date(dateOfBirth), e = new Date(on);
let age = e.getUTCFullYear() - d.getUTCFullYear();
if (e.getUTCMonth() < d.getUTCMonth() || (e.getUTCMonth() === d.getUTCMonth() && e.getUTCDate() < d.getUTCDate())) age -= 1;
return age;
}
/** Whether a child of `age` fits a room's age range (no age or no range = fits). */
function fitsRoom(room, age) {
if (age == null) return true;
if (room.minAge != null && age < room.minAge) return false;
if (room.maxAge != null && age > room.maxAge) return false;
return true;
}
/**
* The room to suggest for a child: rooms whose age range fits, not full, narrowest range
* first (a 2–3 room beats a 0–12 room), then room order. `counts` = { roomId: children in }.
* Returns null when nothing fits.
*/
function suggestRoom(rooms, age, counts = {}) {
const span = r => (r.maxAge ?? 25) - (r.minAge ?? 0);
const fits = rooms.filter(r => fitsRoom(r, age) && !(r.capacity && (counts[r.id] || 0) >= r.capacity));
if (!fits.length) return null;
return [...fits].sort((a, b) => (age == null ? 0 : span(a) - span(b)) || (a.order || 0) - (b.order || 0))[0];
}
/**
* A room's state: children in, leaders on duty, how many leaders the ratio needs, and whether
* it's full or over the ratio.
*/
function roomStatus(room, children, leaders) {
const needed = room.ratio ? Math.ceil(children / room.ratio) : 0;
return {
children,
leaders,
leadersNeeded: needed,
full: !!room.capacity && children >= room.capacity,
overRatio: !!room.ratio && children > 0 && leaders * room.ratio < children,
};
}
/** The short alert copied onto a sticker: "ALLERGY: PEANUTS", "MEDICAL", or both; '' when none. */
function stickerAlert(allergies, medicalNotes) {
const parts = [];
const a = String(allergies || '').trim();
if (a) parts.push(`ALLERGY: ${a.toUpperCase().slice(0, 24)}${a.length > 24 ? '…' : ''}`);
if (String(medicalNotes || '').trim()) parts.push('MEDICAL');
return parts.join(' · ');
}
/**
* The occasion a check-in counts for: { occasion, sessionId }. On a multi-session event it's
* today's session (or the one asked for, if it's open today); on a single-date event it's
* today, while the event is on. Throws when check-in isn't open.
*/
function occasionFor(event, sessions, requestedSessionId = null, now = new Date()) {
if (event.scheduleType === 'sessions') {
const { session, error } = sessionsUtil.scanSession(event, sessions || [], requestedSessionId || null, now);
if (!session) throw kidsError(error || 'No session is open for check-in today.');
return { occasion: session.id, sessionId: session.id };
}
const today = sessionsUtil.dayKey(now);
if (today < sessionsUtil.dayKey(event.startDate) || today > sessionsUtil.dayKey(event.endDate)) {
throw kidsError(`Check-in for this event is open on ${sessionsUtil.dayKey(event.startDate)}${sessionsUtil.dayKey(event.endDate) !== sessionsUtil.dayKey(event.startDate) ? ` to ${sessionsUtil.dayKey(event.endDate)}` : ''}.`);
}
return { occasion: today, sessionId: null };
}
const seal = v => {
const s = String(v ?? '').trim().slice(0, MAX_TEXT);
return s ? encrypt(s) : null;
};
const open = v => (v ? decrypt(v) || '' : '');
function childName(c) {
return [c.firstName, c.lastName].filter(Boolean).join(' ');
}
/** A child for the API. `notes` adds medical and custody notes (staff and the child's parents). */
function childView(c, { on = new Date(), notes = true } = {}) {
const allergies = open(c.allergies);
const medicalNotes = open(c.medicalNotes);
return {
id: c.id,
firstName: c.firstName,
lastName: c.lastName || '',
name: childName(c),
dateOfBirth: c.dateOfBirth,
age: ageOn(c.dateOfBirth, on),
gender: c.gender || null,
allergies,
hasMedical: !!medicalNotes,
hasCustody: !!c.custodyNotes,
...(notes ? { medicalNotes, custodyNotes: open(c.custodyNotes) } : {}),
lastCheckInAt: c.lastCheckInAt || null,
};
}
// ─── Families ─────────────────────────────────────────────────────────────────
const FAMILY_INCLUDE = {
members: { orderBy: { createdAt: 'asc' }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } },
children: { orderBy: [{ dateOfBirth: 'asc' }, { firstName: 'asc' }] },
adults: { orderBy: { createdAt: 'asc' } },
};
const realEmail = e => (e && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(e) ? e : null);
function familyView(f, { notes = true, on = new Date() } = {}) {
return {
id: f.id,
name: f.name,
parents: f.members.map(m => ({ userId: m.user.id, name: m.user.name, phone: m.user.phoneNumber || null, email: realEmail(m.user.email) })),
children: f.children.map(c => childView(c, { notes, on })),
adults: f.adults.map(a => ({ id: a.id, name: a.name, phone: a.phone || null, relationship: a.relationship || null })),
};
}
async function loadFamily(id, db = prisma) {
const f = await db.family.findUnique({ where: { id: String(id || '') }, include: FAMILY_INCLUDE });
if (!f) throw kidsError('Family not found', 404);
return f;
}
/** The signed-in person's family, or null. */
async function familyOfUser(userId, db = prisma) {
const m = await db.familyMember.findUnique({ where: { userId }, select: { familyId: true } });
return m ? db.family.findUnique({ where: { id: m.familyId }, include: FAMILY_INCLUDE }) : null;
}
/** Create a family with `userId` as its first parent (a person is in one family at most). */
async function createFamily({ name, userId }, db = prisma) {
if (await db.familyMember.findUnique({ where: { userId } })) throw kidsError('This person is already a parent in a family.', 409);
const clean = String(name || '').trim().slice(0, 100);
if (!clean) throw kidsError('Enter a name for the family (e.g. "The Smith family").');
const f = await db.family.create({ data: { name: clean, qrToken: crypto.randomBytes(16).toString('hex'), members: { create: { userId } } } });
return loadFamily(f.id, db);
}
function cleanChild(input, existing = null) {
const data = {};
const b = input || {};
if (b.firstName !== undefined || !existing) {
const first = String(b.firstName || '').trim().slice(0, 60);
if (!first) throw kidsError('Enter the child\'s first name.');
data.firstName = first;
}
if (b.lastName !== undefined) data.lastName = String(b.lastName || '').trim().slice(0, 60) || null;
if (b.dateOfBirth !== undefined) {
if (!b.dateOfBirth) data.dateOfBirth = null;
else {
const d = new Date(b.dateOfBirth);
if (Number.isNaN(d.getTime()) || d > new Date() || ageOn(d) > 25) throw kidsError('Enter a valid date of birth.');
data.dateOfBirth = d;
}
}
if (b.gender !== undefined) data.gender = ['male', 'female'].includes(b.gender) ? b.gender : null;
if (b.allergies !== undefined) data.allergies = seal(b.allergies);
if (b.medicalNotes !== undefined) data.medicalNotes = seal(b.medicalNotes);
if (b.custodyNotes !== undefined) data.custodyNotes = seal(b.custodyNotes);
return data;
}
function cleanAdult(input) {
const name = String(input?.name || '').trim().slice(0, 100);
if (!name) throw kidsError('Enter the adult\'s name.');
const { normalizeZAPhone } = require('./whatsapp');
const rawPhone = String(input?.phone || '').trim();
const phone = rawPhone ? (normalizeZAPhone(rawPhone) || rawPhone.replace(/[^\d+]/g, '')).slice(0, 20) : null;
return { name, phone, relationship: String(input?.relationship || '').trim().slice(0, 60) || null };
}
/** Whether custody notes change between the stored child and the cleaned update. */
function custodyChanged(existing, data) {
return data.custodyNotes !== undefined && open(existing?.custodyNotes) !== open(data.custodyNotes);
}
/**
* Families matching a search: family name, a child's name, a parent's name, email or phone.
* Children's names and allergies are encrypted only in part (allergies), so names search fine.
*/
async function searchFamilies(q, db = prisma, { limit = 20 } = {}) {
const term = String(q || '').trim();
if (term.length < 2) return [];
const digits = term.replace(/\D/g, '');
const phoneTerms = digits.length >= 6 ? [digits, digits.startsWith('0') ? '27' + digits.slice(1) : null, digits.startsWith('27') ? '0' + digits.slice(2) : null].filter(Boolean) : [];
const ci = { contains: term, mode: 'insensitive' };
const rows = await db.family.findMany({
where: {
OR: [
{ name: ci },
{ children: { some: { OR: [{ firstName: ci }, { lastName: ci }] } } },
{ members: { some: { user: { OR: [{ name: ci }, { email: ci }, ...phoneTerms.map(p => ({ phoneNumber: { contains: p } }))] } } } },
...phoneTerms.map(p => ({ adults: { some: { phone: { contains: p } } } })),
],
},
include: FAMILY_INCLUDE,
orderBy: { name: 'asc' },
take: limit,
});
return rows;
}
// ─── Events ───────────────────────────────────────────────────────────────────
async function loadKidsEvent(eventId, db = prisma) {
const event = await db.event.findUnique({ where: { id: String(eventId || '') }, include: { sessions: { orderBy: { startsAt: 'asc' } } } });
if (!event) throw kidsError('Event not found', 404);
if (!event.kidsCheckIn || !event.kidsOptionId || !event.kidsRoomSetId) throw kidsError('Kids check-in isn\'t set up for this event.', 400, 'KIDS_NOT_SET_UP');
return event;
}
async function roomsFor(event, db = prisma) {
return db.allocationGroup.findMany({ where: { setId: event.kidsRoomSetId }, orderBy: { order: 'asc' } });
}
/**
* The event's kids settings, with what the supervisor can choose from: main-ticket options
* that cover the whole event, and allocation sets.
*/
async function getSettings(eventId, db = prisma) {
const event = await db.event.findUnique({
where: { id: String(eventId || '') },
select: { id: true, title: true, scheduleType: true, kidsCheckIn: true, kidsOptionId: true, kidsRoomSetId: true },
});
if (!event) throw kidsError('Event not found', 404);
const [options, sets] = await Promise.all([
db.eventOption.findMany({ where: { eventId: event.id, isMainTicket: true, sessionMode: 'all' }, select: { id: true, name: true, price: true }, orderBy: { name: 'asc' } }),
db.allocationSet.findMany({ where: { eventId: event.id }, select: { id: true, name: true, groups: { orderBy: { order: 'asc' }, select: { id: true, name: true, capacity: true, minAge: true, maxAge: true, ratio: true, order: true } } }, orderBy: { order: 'asc' } }),
]);
return { event, options, sets };
}
/**
* Save an event's kids settings. `optionId: 'new'` adds a free "Kids check-in" main ticket;
* `roomSetId: 'new'` adds a "Kids rooms" set. Switching on needs both.
*/
async function saveSettings(eventId, { enabled, optionId, roomSetId }, db = prisma) {
const { event } = await getSettings(eventId, db);
const data = {};
if (optionId !== undefined) {
if (optionId === 'new') {
const opt = await db.eventOption.create({ data: { eventId: event.id, name: 'Kids check-in', price: 0, isMainTicket: true, sessionMode: 'all' } });
data.kidsOptionId = opt.id;
} else if (optionId) {
const opt = await db.eventOption.findFirst({ where: { id: String(optionId), eventId: event.id, isMainTicket: true, sessionMode: 'all' } });
if (!opt) throw kidsError('Choose a main ticket of this event that covers the whole event.');
data.kidsOptionId = opt.id;
} else data.kidsOptionId = null;
}
if (roomSetId !== undefined) {
if (roomSetId === 'new') {
const order = await db.allocationSet.count({ where: { eventId: event.id } });
const set = await db.allocationSet.create({ data: { eventId: event.id, name: 'Kids rooms', order } });
data.kidsRoomSetId = set.id;
} else if (roomSetId) {
const set = await db.allocationSet.findFirst({ where: { id: String(roomSetId), eventId: event.id } });
if (!set) throw kidsError('Choose one of this event\'s allocation sets.');
data.kidsRoomSetId = set.id;
} else data.kidsRoomSetId = null;
}
if (enabled !== undefined) data.kidsCheckIn = !!enabled;
const next = { ...event, ...data };
if (next.kidsCheckIn && (!next.kidsOptionId || !next.kidsRoomSetId)) throw kidsError('Choose the kids ticket and the rooms before switching kids check-in on.');
await db.event.update({ where: { id: event.id }, data });
return getSettings(event.id, db);
}
// ─── Codes ────────────────────────────────────────────────────────────────────
const isUniqueClash = e => e?.code === 'P2002';
/** The family's pickup code for the occasion, made if it has none yet. */
async function codeForFamily(eventId, occasion, familyId, db = prisma) {
for (let i = 0; i < 30; i++) {
const existing = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId, occasion, familyId } } });
if (existing) return existing;
try {
return await db.kidsPickupCode.create({ data: { eventId, occasion, familyId, code: randomCode() } });
} catch (e) {
if (!isUniqueClash(e)) throw e;
// Either the code is taken (try another) or the family got one meanwhile (found above).
}
}
throw kidsError('Could not make a pickup code — try again.', 503);
}
/** Codes held for an offline desk: tops its unused ones up to `count`. */
async function reserveCodes(eventId, occasion, deviceId, count = OFFLINE_CODES, db = prisma) {
const have = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true } });
let need = Math.max(0, count - have.length);
for (let tries = 0; need > 0 && tries < count * 5; tries++) {
try {
await db.kidsPickupCode.create({ data: { eventId, occasion, deviceId, code: randomCode() } });
need--;
} catch (e) {
if (!isUniqueClash(e)) throw e;
}
}
const rows = await db.kidsPickupCode.findMany({ where: { eventId, occasion, deviceId, familyId: null }, select: { code: true }, orderBy: { createdAt: 'asc' } });
return rows.map(r => r.code);
}
// ─── Attendance ───────────────────────────────────────────────────────────────
/**
* The child's ticket for the event: the one from an earlier check-in, or a new free place on
* the kids option — on the parent's kids registration for this event (made the first time,
* separate from any registration they paid for themselves).
*/
async function ticketForChild(tx, event, parentUserId, child) {
const earlier = await tx.kidsCheckIn.findFirst({ where: { eventId: event.id, childId: child.id, ticketId: { not: null } }, select: { ticketId: true, attendeeId: true } });
if (earlier) {
const ticket = await tx.ticket.findUnique({ where: { id: earlier.ticketId }, select: { id: true } });
if (ticket) return { ticketId: ticket.id, attendeeId: earlier.attendeeId };
}
let registration = await tx.registration.findFirst({
where: { userId: parentUserId, eventId: event.id, status: { not: 'cancelled' }, registrationOptions: { some: { eventOptionId: event.kidsOptionId, sessionId: null } } },
include: { registrationOptions: { where: { eventOptionId: event.kidsOptionId, sessionId: null } } },
});
if (!registration) {
registration = await tx.registration.create({ data: { userId: parentUserId, eventId: event.id, status: 'paid' }, include: { registrationOptions: true } });
}
let line = registration.registrationOptions[0];
if (line) line = await tx.registrationOption.update({ where: { id: line.id }, data: { quantity: { increment: 1 } } });
else line = await tx.registrationOption.create({ data: { registrationId: registration.id, eventOptionId: event.kidsOptionId, quantity: 1, priceSnapshot: 0 } });
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: 1, priceSnapshot: 0 } });
const order = await tx.attendee.count({ where: { registrationId: registration.id } });
const parent = await tx.user.findUnique({ where: { id: parentUserId }, select: { name: true, email: true, phoneNumber: true } });
const attendee = await tx.attendee.create({
data: {
registrationId: registration.id, registrationOptionId: line.id, eventOptionId: event.kidsOptionId,
name: childName(child), dateOfBirth: child.dateOfBirth, gender: child.gender,
parentName: parent?.name || null, parentEmail: realEmail(parent?.email), parentPhone: parent?.phoneNumber || null, order,
},
});
const ticket = await tx.ticket.create({
data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: line.id, userId: parentUserId, eventId: event.id, quantity: 1, attendeeId: attendee.id, emailSent: true },
});
return { ticketId: ticket.id, attendeeId: attendee.id };
}
// ─── Check-in and check-out ───────────────────────────────────────────────────
/**
* Check children of a family in. `children` = [{ childId, roomId? }] (room suggested from the
* age when not given). Children already in for this occasion stay as they are (their room can
* change); children checked out earlier today are checked in again. Offline syncs pass the
* code row, `clientId`s and the time it happened.
* Returns { code, family, checkIns, already }.
*/
async function checkIn({ eventId, sessionId = null, familyId, children, staffUserId, now = new Date(), occasionOverride = null, codeRow = null, offline = false }, db = prisma) {
const event = await loadKidsEvent(eventId, db);
const { occasion, sessionId: sid } = occasionOverride || occasionFor(event, event.sessions, sessionId, now);
const family = await loadFamily(familyId, db);
const parent = family.members[0];
if (!parent) throw kidsError('This family has no parent account — add a parent first.');
if (!Array.isArray(children) || !children.length) throw kidsError('Choose the children to check in.');
const rooms = await roomsFor(event, db);
if (!rooms.length) throw kidsError('This event has no kids rooms yet — a supervisor adds them under Kids check-in → Setup.');
const counts = await roomCounts(event.id, occasion, db);
const picks = children.map(p => {
const child = family.children.find(c => c.id === p?.childId);
if (!child) throw kidsError('That child isn\'t in this family.', 404);
let room = p.roomId ? rooms.find(r => r.id === p.roomId) : null;
if (p.roomId && !room) throw kidsError('That room isn\'t one of this event\'s kids rooms.');
if (!room) room = suggestRoom(rooms, ageOn(child.dateOfBirth, now), counts) || rooms[0];
counts[room.id] = (counts[room.id] || 0) + 1;
return { child, room, clientId: p.clientId || null };
});
const code = codeRow || await codeForFamily(event.id, occasion, family.id, db);
const at = new Date(now);
const result = [];
const already = [];
await db.$transaction(async tx => {
for (const { child, room, clientId } of picks) {
const existing = await tx.kidsCheckIn.findUnique({ where: { eventId_occasion_childId: { eventId: event.id, occasion, childId: child.id } } });
if (existing && !existing.checkedOutAt) {
if (existing.roomId !== room.id && !offline) await tx.kidsCheckIn.update({ where: { id: existing.id }, data: { roomId: room.id } });
already.push(existing.id);
result.push(existing.id);
continue;
}
if (existing) {
await tx.kidsCheckIn.update({
where: { id: existing.id },
data: { roomId: room.id, pickupCodeId: code.id, checkedInAt: at, checkedInById: staffUserId, checkedOutAt: null, checkedOutById: null, collectedBy: null, overrideReason: null },
});
result.push(existing.id);
continue;
}
const { ticketId, attendeeId } = await ticketForChild(tx, event, parent.userId, child);
await tx.ticketUsage.create({ data: { ticketId, scannedById: staffUserId, scannedAt: at, sessionId: sid, offline, syncedAt: offline ? new Date() : null } });
if (event.scheduleType !== 'sessions') await tx.ticket.update({ where: { id: ticketId }, data: { isUsed: true } });
const row = await tx.kidsCheckIn.create({
data: {
eventId: event.id, occasion, sessionId: sid, childId: child.id, childName: childName(child), roomId: room.id,
pickupCodeId: code.id, attendeeId, ticketId, checkedInAt: at, checkedInById: staffUserId, clientId, offline,
},
});
result.push(row.id);
}
await tx.child.updateMany({ where: { id: { in: picks.map(p => p.child.id) } }, data: { lastCheckInAt: at } });
});
const checkIns = await db.kidsCheckIn.findMany({ where: { id: { in: result } }, include: { room: { select: { id: true, name: true } }, child: true } });
return { event, occasion, sessionId: sid, code: code.code, family, checkIns, already };
}
/** Children in per room for an occasion: { roomId: n }. */
async function roomCounts(eventId, occasion, db = prisma) {
const rows = await db.kidsCheckIn.groupBy({ by: ['roomId'], where: { eventId, occasion, checkedOutAt: null }, _count: { _all: true } });
return Object.fromEntries(rows.filter(r => r.roomId).map(r => [r.roomId, r._count._all]));
}
/**
* Check out with a pickup code. `checkInIds` limits it to some of the children on the code
* (default all still in). A code that matches nothing raises a wrong-code alert for the room
* and throws WRONG_CODE.
*/
async function checkOut({ eventId, sessionId = null, code: rawCode, checkInIds = null, collectedBy, roomId = null, staffUserId, now = new Date() }, db = prisma) {
const event = await loadKidsEvent(eventId, db);
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
const code = normaliseCode(rawCode);
const row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
const open = row ? await db.kidsCheckIn.findMany({ where: { pickupCodeId: row.id, checkedOutAt: null, ...(checkInIds?.length ? { id: { in: checkInIds.map(String) } } : {}) } }) : [];
if (!open.length) {
const typed = String(rawCode || '').trim().slice(0, 20);
const message = row ? `Code ${code} has no children still checked in.` : `Wrong pickup code entered: "${typed}".`;
if (!row) await db.kidsAlert.create({ data: { eventId: event.id, occasion, kind: 'wrong_code', roomId: roomId || null, message, createdById: staffUserId } });
throw kidsError(row ? message : 'That pickup code doesn\'t match any child checked in. The room leader has been alerted.', 404, row ? 'NOTHING_TO_COLLECT' : 'WRONG_CODE');
}
const who = String(collectedBy || '').trim().slice(0, 100) || null;
await db.kidsCheckIn.updateMany({ where: { id: { in: open.map(c => c.id) } }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who } });
return { event, occasion, checkIns: await db.kidsCheckIn.findMany({ where: { id: { in: open.map(c => c.id) } }, include: { room: { select: { id: true, name: true } } } }) };
}
/** Check one child out without the code (lost phone, etc.). The reason is required and logged. */
async function overrideCheckOut({ checkInId, reason, collectedBy, staffUserId, now = new Date() }, db = prisma) {
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') } });
if (!ci) throw kidsError('Check-in not found', 404);
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
const why = String(reason || '').trim().slice(0, 300);
if (why.length < 5) throw kidsError('Give the reason for checking out without the code.');
const who = String(collectedBy || '').trim().slice(0, 100);
if (!who) throw kidsError('Enter who collected the child.');
const updated = await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: new Date(now), checkedOutById: staffUserId, collectedBy: who, overrideReason: why } });
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'override', roomId: ci.roomId, checkInId: ci.id, message: `${ci.childName} checked out without the code by ${who}: ${why}`, createdById: staffUserId, resolvedAt: new Date(now) } });
return updated;
}
// ─── Messages ─────────────────────────────────────────────────────────────────
function listNames(names) {
return names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
/** The check-in message: who, where and the code. Never medical or custody details. */
// `forParent` adds where to find the QR code (authorised adults have no account to look in).
function checkInMessage({ eventTitle, code, children, forParent = true }) {
const names = children.map(c => c.name);
const rooms = [...new Set(children.map(c => c.room).filter(Boolean))];
const how = forParent ? `Show this code — or the QR code in My account → Kids (${frontendUrl()}/dashboard/user/kids) —` : 'Show this code';
return `${listNames(names)} ${names.length === 1 ? 'is' : 'are'} checked in at ${eventTitle}${rooms.length ? ` (${rooms.join(', ')})` : ''}. Pickup code: ${code}. ${how} to collect ${names.length === 1 ? 'them' : 'them all'}.`;
}
/** Send a text to a parent the way they like it (WhatsApp, email, or whichever works). */
async function messageParent(user, { subject, heading, text }) {
const { canWhatsApp, shouldEmail, waTextAny } = require('./notify');
const email = realEmail(user.email);
const sent = [];
if (canWhatsApp(user) || (!email && user.phoneNumber)) {
await waTextAny(user, text);
sent.push('whatsapp');
}
if (email && (shouldEmail(user) || !sent.length)) {
const { sendMail, buildPrivacyEmail } = require('./email');
await sendMail({ to: email, subject, ...buildPrivacyEmail({ name: user.name, heading, paragraphs: [text] }) }).catch(() => {});
sent.push('email');
}
return sent;
}
/** Tell the family's parents (and an authorised adult, if chosen) the code. Fire-and-forget. */
async function sendCheckInMessages({ event, family, code, checkIns, adultId = null }) {
const children = checkIns.map(ci => ({ name: ci.child?.firstName || ci.childName, room: ci.room?.name || null }));
const text = checkInMessage({ eventTitle: event.title, code, children });
for (const m of family.members) {
await messageParent(m.user, { subject: `Pickup code ${code} — ${event.title}`, heading: `Pickup code ${code}`, text }).catch(() => {});
}
const adult = adultId ? family.adults.find(a => a.id === adultId) : null;
if (adult?.phone) {
const { sendText } = require('./whatsapp');
await sendText(adult.phone, `${family.name}: ${checkInMessage({ eventTitle: event.title, code, children, forParent: false })}`).catch(() => {});
}
}
/** Page a parent: "Please come to the Toddlers room". Logged as an alert the parent sees too. */
async function pageParent({ checkInId, message, staffUserId }, db = prisma) {
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { room: true, event: { select: { title: true } }, pickupCode: { select: { familyId: true } } } });
if (!ci) throw kidsError('Check-in not found', 404);
if (ci.checkedOutAt) throw kidsError(`${ci.childName} has already been collected.`);
const family = ci.pickupCode.familyId ? await loadFamily(ci.pickupCode.familyId, db) : null;
if (!family?.members.length) throw kidsError('There\'s no parent to contact for this child.');
const extra = String(message || '').trim().slice(0, 300);
const first = ci.childName.split(' ')[0];
const text = `Please come to the ${ci.room?.name || 'kids'} room for ${first} (${ci.event.title}).${extra ? ` ${extra}` : ''}`;
const channels = new Set();
for (const m of family.members) {
for (const c of await messageParent(m.user, { subject: `Please come to the ${ci.room?.name || 'kids'} room`, heading: `Please come to the ${ci.room?.name || 'kids'} room`, text }).catch(() => [])) channels.add(c);
}
await db.kidsAlert.create({ data: { eventId: ci.eventId, occasion: ci.occasion, kind: 'page_parent', roomId: ci.roomId, checkInId: ci.id, message: text, createdById: staffUserId } });
return { sent: [...channels], text };
}
// ─── Boards ───────────────────────────────────────────────────────────────────
/**
* Everything the desk and room screens show for an occasion: rooms with counts and leaders on
* duty, the children checked in (with allergies, medical and custody notes), open alerts.
*/
async function board({ eventId, sessionId = null, now = new Date() }, db = prisma) {
const event = await loadKidsEvent(eventId, db);
const { occasion, sessionId: sid } = occasionFor(event, event.sessions, sessionId, now);
const [rooms, checkIns, duties, alerts] = await Promise.all([
roomsFor(event, db),
db.kidsCheckIn.findMany({
where: { eventId: event.id, occasion },
include: {
child: true,
pickupCode: { select: { code: true, familyId: true } },
checkedInBy: { select: { name: true } },
checkedOutBy: { select: { name: true } },
},
orderBy: { checkedInAt: 'asc' },
}),
db.kidsRoomDuty.findMany({ where: { occasion, endedAt: null, room: { setId: event.kidsRoomSetId } }, include: { user: { select: { id: true, name: true } } } }),
db.kidsAlert.findMany({ where: { eventId: event.id, occasion }, orderBy: { createdAt: 'desc' }, take: 50, include: { createdBy: { select: { name: true } } } }),
]);
const familyIds = [...new Set(checkIns.map(c => c.pickupCode.familyId).filter(Boolean))];
const families = familyIds.length ? await db.family.findMany({ where: { id: { in: familyIds } }, include: FAMILY_INCLUDE }) : [];
const famById = Object.fromEntries(families.map(f => [f.id, f]));
const inByRoom = {};
for (const c of checkIns) if (!c.checkedOutAt && c.roomId) inByRoom[c.roomId] = (inByRoom[c.roomId] || 0) + 1;
const sessionLabel = sid ? sessionsUtil.sessionLabel(event.sessions.find(s => s.id === sid), event.sessions) : null;
return {
event: { id: event.id, title: event.title, scheduleType: event.scheduleType },
occasion,
sessionId: sid,
sessionLabel,
rooms: rooms.map(r => {
const leaders = duties.filter(d => d.roomId === r.id).map(d => d.user);
return { id: r.id, name: r.name, minAge: r.minAge, maxAge: r.maxAge, capacity: r.capacity, ratio: r.ratio, order: r.order, leaderNames: leaders.map(l => l.name), leaderIds: leaders.map(l => l.id), ...roomStatus(r, inByRoom[r.id] || 0, leaders.length) };
}),
checkIns: checkIns.map(c => {
const fam = famById[c.pickupCode.familyId];
const child = c.child ? childView(c.child, { on: now }) : null;
return {
id: c.id,
childId: c.childId,
name: c.childName,
firstName: child?.firstName || c.childName.split(' ')[0],
age: child?.age ?? null,
roomId: c.roomId,
code: c.pickupCode.code,
checkedInAt: c.checkedInAt,
checkedInBy: c.checkedInBy?.name || null,
checkedOutAt: c.checkedOutAt,
checkedOutBy: c.checkedOutBy?.name || null,
collectedBy: c.collectedBy,
override: !!c.overrideReason,
offline: c.offline,
allergies: child?.allergies || '',
medicalNotes: child?.medicalNotes || '',
custodyNotes: child?.custodyNotes || '',
sticker: stickerAlert(child?.allergies, child?.medicalNotes),
family: fam ? { id: fam.id, name: fam.name, parents: familyView(fam, { notes: false }).parents, adults: familyView(fam, { notes: false }).adults } : null,
};
}),
alerts: alerts.map(a => ({ id: a.id, kind: a.kind, roomId: a.roomId, checkInId: a.checkInId, message: a.message, by: a.createdBy?.name || null, at: a.createdAt, resolvedAt: a.resolvedAt })),
};
}
/** "I'm on duty" / "I'm off" in a room for the occasion. */
async function setDuty({ eventId, sessionId = null, roomId, userId, on, now = new Date() }, db = prisma) {
const event = await loadKidsEvent(eventId, db);
const { occasion } = occasionFor(event, event.sessions, sessionId, now);
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: event.kidsRoomSetId } });
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
// One room at a time: going on duty somewhere ends duty elsewhere for this occasion.
await db.kidsRoomDuty.updateMany({ where: { userId, occasion, endedAt: null, ...(on ? {} : { roomId: room.id }) }, data: { endedAt: new Date(now) } });
if (on) await db.kidsRoomDuty.create({ data: { roomId: room.id, occasion, userId } });
return { roomId: room.id, on: !!on };
}
/** Move a child to another room. */
async function moveRoom({ checkInId, roomId }, db = prisma) {
const ci = await db.kidsCheckIn.findUnique({ where: { id: String(checkInId || '') }, include: { event: { select: { kidsRoomSetId: true } } } });
if (!ci) throw kidsError('Check-in not found', 404);
const room = await db.allocationGroup.findFirst({ where: { id: String(roomId || ''), setId: ci.event.kidsRoomSetId } });
if (!room) throw kidsError('That room isn\'t one of this event\'s kids rooms.', 404);
return db.kidsCheckIn.update({ where: { id: ci.id }, data: { roomId: room.id } });
}
// ─── The parent's view ────────────────────────────────────────────────────────
/** The parent's live codes: children checked in now (not collected), grouped by code, plus pages from today. */
async function activeForFamily(familyId, now = new Date(), db = prisma) {
const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const checkIns = await db.kidsCheckIn.findMany({
where: { pickupCode: { familyId }, checkedOutAt: null, checkedInAt: { gte: since } },
include: { pickupCode: { select: { code: true } }, room: { select: { name: true } }, event: { select: { id: true, title: true } } },
orderBy: { checkedInAt: 'asc' },
});
const byCode = new Map();
for (const c of checkIns) {
const key = `${c.eventId}:${c.pickupCode.code}`;
if (!byCode.has(key)) byCode.set(key, { code: c.pickupCode.code, qr: QR_PREFIX + c.pickupCode.code, event: c.event, children: [] });
byCode.get(key).children.push({ name: c.childName, room: c.room?.name || null, checkedInAt: c.checkedInAt });
}
const pages = checkIns.length ? await db.kidsAlert.findMany({
where: { kind: 'page_parent', checkInId: { in: checkIns.map(c => c.id) }, createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
select: { id: true, message: true, createdAt: true },
}) : [];
return { codes: [...byCode.values()], pages };
}
// ─── Offline desks ────────────────────────────────────────────────────────────
/**
* What a desk downloads to keep checking in without a connection: the families (with
* children), the rooms, who is already in, and codes reserved for this device.
*/
async function offlineSnapshot({ eventId, sessionId = null, deviceId, now = new Date() }, db = prisma) {
const dev = String(deviceId || '').trim().slice(0, 64);
if (!dev) throw kidsError('deviceId is required');
const b = await board({ eventId, sessionId, now }, db);
const event = await loadKidsEvent(eventId, db);
const families = await db.family.findMany({ where: { children: { some: {} }, members: { some: {} } }, include: FAMILY_INCLUDE, orderBy: { name: 'asc' }, take: 3000 });
const codes = await reserveCodes(event.id, b.occasion, dev, OFFLINE_CODES, db);
return {
occasion: b.occasion,
sessionId: b.sessionId,
sessionLabel: b.sessionLabel,
event: b.event,
rooms: b.rooms,
checkIns: b.checkIns,
families: families.map(f => familyView(f, { on: now })),
codes,
downloadedAt: new Date(now).toISOString(),
};
}
/**
* Sync an offline desk. `checkIns` = [{ clientId, familyId, childId, roomId, code, at }] made
* with the device's reserved codes; `checkOuts` = [{ clientId, collectedBy, at }] for children
* checked in on this device. Idempotent (clientId). Returns { checkedIn, checkedOut, conflicts }.
*/
async function syncOffline({ eventId, deviceId, occasion, sessionId = null, checkIns = [], checkOuts = [], staffUserId }, db = prisma) {
const event = await loadKidsEvent(eventId, db);
const dev = String(deviceId || '').trim().slice(0, 64);
const occ = String(occasion || '');
const validOccasion = event.scheduleType === 'sessions' ? event.sessions.some(s => s.id === occ) : /^\d{4}-\d{2}-\d{2}$/.test(occ);
if (!dev || !validOccasion) throw kidsError('This offline list isn\'t for this event.');
const sid = event.scheduleType === 'sessions' ? occ : null;
const out = { checkedIn: 0, checkedOut: 0, conflicts: [] };
for (const item of (Array.isArray(checkIns) ? checkIns : []).slice(0, 500)) {
const clientId = String(item?.clientId || '').slice(0, 64);
if (!clientId) continue;
if (await db.kidsCheckIn.findUnique({ where: { clientId } })) continue;
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
try {
const code = normaliseCode(item.code);
let row = code ? await db.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion: occ, code } } }) : null;
if (!row || (row.deviceId !== dev && row.familyId !== item.familyId)) throw kidsError(`Code ${item.code} wasn't reserved for this device.`);
if (!row.familyId) {
const theirs = await db.kidsPickupCode.findUnique({ where: { eventId_occasion_familyId: { eventId: event.id, occasion: occ, familyId: String(item.familyId || '') } } });
if (theirs) {
out.conflicts.push({ clientId, message: `This family already had code ${theirs.code} from another desk — tell the parent their code is ${theirs.code}, not ${code}.` });
row = theirs;
} else {
row = await db.kidsPickupCode.update({ where: { id: row.id }, data: { familyId: String(item.familyId || '') } });
}
}
await checkIn({
eventId: event.id, familyId: item.familyId, children: [{ childId: item.childId, roomId: item.roomId, clientId }],
staffUserId, now: at, occasionOverride: { occasion: occ, sessionId: sid }, codeRow: row, offline: true,
}, db);
out.checkedIn++;
} catch (e) {
out.conflicts.push({ clientId, message: e.message || 'Could not sync this check-in.' });
}
}
for (const item of (Array.isArray(checkOuts) ? checkOuts : []).slice(0, 500)) {
const ci = item?.clientId ? await db.kidsCheckIn.findUnique({ where: { clientId: String(item.clientId) } }) : null;
if (!ci) { out.conflicts.push({ clientId: item?.clientId || null, message: 'A check-out was for a check-in that never synced.' }); continue; }
if (ci.checkedOutAt) continue;
const at = item.at && !Number.isNaN(new Date(item.at).getTime()) ? new Date(item.at) : new Date();
await db.kidsCheckIn.update({ where: { id: ci.id }, data: { checkedOutAt: at, checkedOutById: staffUserId, collectedBy: String(item.collectedBy || '').trim().slice(0, 100) || null } });
out.checkedOut++;
}
return out;
}
// ─── Privacy ──────────────────────────────────────────────────────────────────
/** For anonymiseUser: leave the family; a family nobody is left in is deleted with its children. */
async function removeFromFamily(tx, userId) {
const m = await tx.familyMember.findUnique({ where: { userId } });
if (!m) return { familyDeleted: false };
await tx.familyMember.delete({ where: { id: m.id } });
const left = await tx.familyMember.count({ where: { familyId: m.familyId } });
if (left) return { familyDeleted: false };
await deleteFamilyData(tx, m.familyId);
return { familyDeleted: true };
}
/** Delete a family and its children; check-ins keep the attendance but lose who collected. */
async function deleteFamilyData(tx, familyId) {
await tx.kidsCheckIn.updateMany({ where: { pickupCode: { familyId } }, data: { collectedBy: null, overrideReason: null } });
await tx.family.delete({ where: { id: familyId } });
}
/** For "Download my data". */
async function exportFamily(userId, db = prisma) {
const f = await familyOfUser(userId, db);
if (!f) return null;
const checkIns = await db.kidsCheckIn.findMany({
where: { pickupCode: { familyId: f.id } },
include: { event: { select: { title: true } }, room: { select: { name: true } } },
orderBy: { checkedInAt: 'asc' },
});
const v = familyView(f);
return {
name: v.name,
parents: v.parents.map(p => p.name),
children: v.children.map(c => ({ name: c.name, dateOfBirth: c.dateOfBirth, gender: c.gender, allergies: c.allergies, medicalNotes: c.medicalNotes, custodyNotes: c.custodyNotes })),
authorisedAdults: v.adults,
checkIns: checkIns.map(c => ({ child: c.childName, event: c.event?.title, room: c.room?.name || null, in: c.checkedInAt, out: c.checkedOutAt, collectedBy: c.collectedBy })),
};
}
/**
* Retention: children not checked in (or added) for `months` are deleted, then families left
* with no children. Who collected children (and override reasons) is cleared from check-ins as
* old; alerts that old are deleted.
*/
async function runKidsRetention(cutoff, db = prisma) {
const children = await db.child.deleteMany({
where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] },
});
const emptyFamilies = await db.family.findMany({ where: { children: { none: {} }, updatedAt: { lt: cutoff } }, select: { id: true } });
for (const f of emptyFamilies) await db.$transaction(tx => deleteFamilyData(tx, f.id));
const scrubbed = await db.kidsCheckIn.updateMany({ where: { checkedInAt: { lt: cutoff }, OR: [{ collectedBy: { not: null } }, { overrideReason: { not: null } }] }, data: { collectedBy: null, overrideReason: null } });
const alerts = await db.kidsAlert.deleteMany({ where: { createdAt: { lt: cutoff } } });
const duties = await db.kidsRoomDuty.deleteMany({ where: { startedAt: { lt: cutoff } } });
return { children: children.count, families: emptyFamilies.length, checkIns: scrubbed.count, alerts: alerts.count + duties.count };
}
async function previewKidsRetention(cutoff, db = prisma) {
return {
count: await db.child.count({ where: { OR: [{ lastCheckInAt: { lt: cutoff } }, { lastCheckInAt: null, createdAt: { lt: cutoff } }] } }),
};
}
module.exports = {
CODE_ALPHABET, CODE_LENGTH, QR_PREFIX, kidsError,
randomCode, normaliseCode, ageOn, fitsRoom, suggestRoom, roomStatus, stickerAlert, occasionFor, checkInMessage, listNames,
childView, familyView, loadFamily, familyOfUser, createFamily, cleanChild, cleanAdult, custodyChanged, searchFamilies,
loadKidsEvent, roomsFor, getSettings, saveSettings,
codeForFamily, reserveCodes, checkIn, checkOut, overrideCheckOut, sendCheckInMessages, pageParent,
board, setDuty, moveRoom, activeForFamily, offlineSnapshot, syncOffline,
removeFromFamily, exportFamily, runKidsRetention, previewKidsRetention,
};
+8 -1
View File
@@ -1422,7 +1422,13 @@ async function sendReceiptToUser(paymentId, requesterId, channel) {
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
// Campus summary recipients (Site settings → Campuses) also get their campus's events.
const campusesOn = await require('./campuses').campusesEnabled();
const notifyInclude = {
createdBy: { select: { id: true, name: true, email: true } },
notifyRecipients: { select: { id: true, name: true, email: true } },
...(campusesOn ? { campus: { select: { notifyRecipients: { select: { email: true } } } } } : {}),
};
let events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
include: notifyInclude,
@@ -1462,6 +1468,7 @@ async function sendDailyEventSummaries(now = new Date()) {
: 0;
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
const to = buildEventNotifyRecipientList(ev);
for (const u of ev.campus?.notifyRecipients || []) if (u.email && !to.includes(u.email)) to.push(u.email);
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
}));
} catch (e) {
+10 -2
View File
@@ -404,6 +404,7 @@ async function anonymiseUser(userId, db = prisma) {
tokenVersion: { increment: 1 },
newsEmail: false,
newsWhatsApp: false,
homeCampusId: null,
},
});
@@ -454,7 +455,12 @@ async function anonymiseUser(userId, db = prisma) {
await tx.passwordReset.deleteMany({ where: { userId } });
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length };
// Kids check-in: leave their family; a family with no parent left is deleted with its
// children (their check-ins stay as attendance, without who collected them).
const { familyDeleted } = await require('./kids').removeFromFamily(tx, userId);
await tx.kidsCheckIn.updateMany({ where: { checkedOutById: userId }, data: { checkedOutById: null } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted };
});
await clearTrustedDevices(userId);
@@ -476,7 +482,7 @@ async function exportUserData(userId, db = prisma) {
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -500,6 +506,7 @@ async function exportUserData(userId, db = prisma) {
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
require('./kids').exportFamily(userId, db),
]);
return {
@@ -541,6 +548,7 @@ async function exportUserData(userId, db = prisma) {
feedback: feedback.map(f => ({ event: f.form?.event?.title, at: f.createdAt, answers: f.answers.map(a => ({ question: a.field?.label, answer: a.value })) })),
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
dataRequests: requests,
kidsFamily: family,
};
}
+1
View File
@@ -182,6 +182,7 @@ const DEFAULT_SECTIONS = [
'• hope_events_device — only if you tick "Trust this device" when entering a two-factor code, so you aren\'t asked for a code every time.',
'• hope_events_cookie_consent — remembers your choice on the cookie notice.',
'• banner_dismissed_… — remembers notices you have closed.',
'• hope_events_campus — only on churches with more than one campus: the campus you chose on the events page, so it stays chosen.',
'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.',
+4
View File
@@ -79,6 +79,10 @@ async function readiness(now = new Date(), db = prisma) {
add('whatsapp_stop', 'WhatsApp STOP replies reach the site', seen && now - seen < 30 * DAY,
seen ? `Last message from WAWP ${seen.toLocaleString('en-ZA')}${now - seen >= 30 * DAY ? ' — over a month ago, check the webhook' : ''}` : 'Paste the webhook URL from Site settings → WhatsApp into WAWP (events: message, session.status).',
'/dashboard/admin/settings?tab=whatsapp', whatsappConfigured);
const kidsEvents = await db.event.count({ where: { kidsCheckIn: true } });
add('kids_retention', 'Children\'s kids check-in details expire', policies.policies.kids_profiles?.enabled,
policies.policies.kids_profiles?.enabled ? `Deleted after ${retention.describeMonths(policies.policies.kids_profiles.months)} without a check-in` : 'Switch on the "Children at kids check-in" retention rule — it holds children\'s medical and custody notes.',
'/dashboard/admin/privacy?tab=retention', kidsEvents > 0);
const twoFactorOn = String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() !== 'false';
add('two_factor', 'Two-factor login required for admins and supervisors', twoFactorOn,
twoFactorOn ? 'Required' : 'TWO_FACTOR_REQUIRED_ROLES=false on the server — only for emergencies.', null);
+15 -1
View File
@@ -43,6 +43,12 @@ const POLICIES = {
defaults: { enabled: true, months: 24 },
minMonths: 3,
},
kids_profiles: {
label: 'Children at kids check-in',
description: 'A child\'s profile (name, date of birth, allergies, medical and custody notes) is deleted when they haven\'t been checked in for this long, then families left without children. Who collected a child is cleared from check-ins this old; the attendance itself stays.',
defaults: { enabled: true, months: 6 },
minMonths: 1,
},
audit_logs: {
label: 'Audit and access logs',
description: 'The audit log, the access log and "view as" sessions.',
@@ -227,7 +233,12 @@ async function runAuditLogs(policy, now, db = prisma) {
return { audit: audit.count, access: access.count, viewAsSessions: sessions.count };
}
async function runKidsProfiles(policy, now, db = prisma) {
return require('./kids').runKidsRetention(monthsAgo(policy.months, now), db);
}
const RUNNERS = {
kids_profiles: runKidsProfiles,
inactive_accounts: runInactiveAccounts,
form_answers: runFormAnswers,
login_history: runLoginHistory,
@@ -267,12 +278,13 @@ async function preview(policies, now = new Date(), db = prisma) {
const out = {};
const p = policies;
const inactiveCutoff = monthsAgo(p.inactive_accounts.months, now);
const [toWarn, warnedDue, formAnswers, logins, audit] = await Promise.all([
const [toWarn, warnedDue, formAnswers, logins, audit, kidsProfiles] = await Promise.all([
inactiveAccounts(new Date(inactiveCutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: 5000 }, db),
inactiveAccounts(inactiveCutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: 5000 }, db),
db.formAnswer.count({ where: { response: { registration: { event: { endDate: { lt: monthsAgo(p.form_answers.months, now) } } } } } }),
db.securityEvent.count({ where: { createdAt: { lt: monthsAgo(p.login_history.months, now) } } }),
db.adminAuditLog.count({ where: { createdAt: { lt: monthsAgo(p.audit_logs.months, now) } } }),
require('./kids').previewKidsRetention(monthsAgo(p.kids_profiles.months, now), db),
]);
const payments = await db.$queryRawUnsafe(
`SELECT COUNT(*)::int AS n FROM "ProviderTransaction" WHERE "createdAt" < $1 AND "raw" IS NOT NULL AND jsonb_typeof("raw") = 'object' AND NOT ("raw" ? '_scrubbed')`,
@@ -283,6 +295,7 @@ async function preview(policies, now = new Date(), db = prisma) {
out.login_history = { count: logins };
out.payment_details = { count: payments[0]?.n || 0 };
out.audit_logs = { count: audit };
out.kids_profiles = kidsProfiles;
return out;
}
@@ -298,6 +311,7 @@ async function noticeLines(db = prisma) {
`• Medical and emergency-contact details from parental consent forms are deleted ${parseInt(medical, 10) || 30} days after the event. The record of who gave consent, when and to which wording is kept.`,
line('login_history', t => `• Login history (time, IP address, device) is kept for ${t}.`, '• Login history is kept until you ask us to delete it.'),
line('payment_details', t => `• Payer names and email addresses received with payments are removed after ${t}. Payment records themselves (amount, date, method) are kept as tax law requires.`, '• Payment records are kept as tax law requires.'),
line('kids_profiles', t => `• Children's details for kids check-in (including allergies, medical and custody notes) are deleted when a child hasn't been checked in for ${t}.`, '• Children\'s details for kids check-in are kept until a parent removes them.'),
line('audit_logs', t => `• Audit and access records (who did what in the system) are kept for ${t}.`, '• Audit and access records are kept for accountability.'),
'• Detailed visit statistics become anonymous daily totals after 90 days.',
`• Database backups are kept for about ${parseInt(backups, 10) || 14} days, so deleted information stays in them until they are replaced.`,
+124
View File
@@ -0,0 +1,124 @@
const cs = require('../src/utils/campusScope');
const north = { campusIds: ['north'], names: ['North'] };
describe('campus scope rules', () => {
test('unscoped sees and manages everything', () => {
expect(cs.canView(null, 'south')).toBe(true);
expect(cs.canManage(null, null)).toBe(true);
expect(cs.eventWhere(null)).toEqual({});
});
test('scoped: own campus and church-wide events are visible, other campuses are not', () => {
expect(cs.canView(north, 'north')).toBe(true);
expect(cs.canView(north, null)).toBe(true);
expect(cs.canView(north, 'south')).toBe(false);
});
test('scoped: only own campus events can be managed — not church-wide ones', () => {
expect(cs.canManage(north, 'north')).toBe(true);
expect(cs.canManage(north, null)).toBe(false);
expect(cs.canManage(north, 'south')).toBe(false);
});
test('list filters, also through relations', () => {
const ev = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
expect(cs.eventWhere(north)).toEqual(ev);
expect(cs.manageWhere(north)).toEqual({ campusId: { in: ['north'] } });
expect(cs.relWhere(north, 'registration.event')).toEqual({ registration: { event: ev } });
expect(cs.relWhere(null, 'event')).toEqual({});
});
test('withScope never overwrites the other filter', () => {
const own = { OR: [{ a: 1 }, { b: 2 }] };
const scope = cs.eventWhere(north);
expect(cs.withScope(own, scope)).toEqual({ AND: [own, scope] });
expect(cs.withScope(own, {})).toBe(own);
expect(cs.withScope({}, scope)).toBe(scope);
});
test('coupons: only when limited to events, all at own campuses', () => {
expect(cs.couponAllowed(null, [])).toBe(true);
expect(cs.couponAllowed(north, [])).toBe(false); // every event
expect(cs.couponAllowed(north, ['north'])).toBe(true);
expect(cs.couponAllowed(north, ['north', null])).toBe(false); // a church-wide event
expect(cs.couponAllowed(north, ['north', 'south'])).toBe(false);
});
});
describe('campus scope middleware', () => {
// The scope is preset on the request (scopeFor caches it there) and the resolvers are stubbed,
// so these run without a database.
const saved = { ...cs.RESOLVERS };
const events = {
n1: { id: 'n1', campusId: 'north', campusName: 'North', owners: ['u-owner'] },
s1: { id: 's1', campusId: 'south', campusName: 'South', owners: ['u-owner'] },
all: { id: 'all', campusId: null, campusName: null, owners: [] },
};
beforeAll(() => {
for (const k of ['event', 'registration', 'ticketQr', 'payment']) cs.RESOLVERS[k] = async id => events[id] || null;
});
afterAll(() => Object.assign(cs.RESOLVERS, saved));
const run = async (mw, { scope = north, params = {}, body = {}, userId = 'u-staff' } = {}) => {
const req = { _campusScope: scope, params, body, user: { id: userId, role: 'supervisor' } };
const out = { status: 200, body: null, next: false };
const res = { status(c) { out.status = c; return this; }, json(b) { out.body = b; return this; } };
await mw(req, res, () => { out.next = true; });
return out;
};
test('scopeEvent: own campus and church-wide pass, another campus is "not found"', async () => {
const mw = cs.scopeEvent();
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
expect((await run(mw, { params: { id: 'all' } })).next).toBe(true);
const other = await run(mw, { params: { id: 's1' } });
expect(other.next).toBe(false);
expect(other.status).toBe(404);
});
test('scopeEvent: unscoped always passes; unknown records pass to the controller', async () => {
expect((await run(cs.scopeEvent(), { scope: null, params: { id: 's1' } })).next).toBe(true);
expect((await run(cs.scopeEvent(), { params: { id: 'nope' } })).next).toBe(true);
});
test("scopeEvent need 'manage': church-wide events are refused with a reason", async () => {
const mw = cs.scopeEvent({ need: 'manage' });
expect((await run(mw, { params: { id: 'n1' } })).next).toBe(true);
const all = await run(mw, { params: { id: 'all' } });
expect(all.status).toBe(403);
expect(all.body.code).toBe('CHURCH_WIDE_EVENT');
});
test('scopeEvent ownerOk: the owner of another campus\'s record still gets through', async () => {
const mw = cs.scopeEvent({ resolve: 'registration', ownerOk: true });
expect((await run(mw, { params: { id: 's1' }, userId: 'u-owner' })).next).toBe(true);
expect((await run(mw, { params: { id: 's1' } })).status).toBe(404);
});
test('the scanner names the campus of another campus\'s ticket', async () => {
const mw = cs.scopeEvent({ resolve: 'ticketQr', from: r => r.params.qrCode, nameCampus: true });
const out = await run(mw, { params: { qrCode: 's1' } });
expect(out.status).toBe(403);
expect(out.body).toEqual({ code: 'OTHER_CAMPUS', message: 'This ticket is for an event at South.' });
});
test('scopeBody: every target and every id in a list is checked', async () => {
const mw = cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']]);
cs.RESOLVERS.ticket = async id => events[id] || null;
expect((await run(mw, { body: { ticketIds: ['n1', 'all'] } })).next).toBe(true);
expect((await run(mw, { body: { ticketIds: ['n1', 's1'] } })).status).toBe(404);
});
test("scopeBody orphan 'deny': a payment not linked to any event is unscoped-only", async () => {
const mw = cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' });
expect((await run(mw, { body: {} })).status).toBe(403);
expect((await run(mw, { body: { eventId: 'n1' } })).next).toBe(true);
expect((await run(mw, { scope: null, body: {} })).next).toBe(true);
});
test('unscopedOnly refuses scoped users', async () => {
expect((await run(cs.unscopedOnly)).status).toBe(403);
expect((await run(cs.unscopedOnly, { scope: null })).next).toBe(true);
});
});
+46
View File
@@ -0,0 +1,46 @@
// Every staff/supervisor route must say how it handles campus-scoped staff (utils/campusScope.js):
// scopeEvent(...), scopeList, unscopedOnly or campusIndependent('reason'). A route that says
// nothing would show a campus-limited supervisor every campus's data, so this fails on it.
const fs = require('fs');
const path = require('path');
const { staff, supervisor } = require('../src/middleware/authMiddleware');
const ROUTES_DIR = path.join(__dirname, '..', 'src', 'routes');
function staffRoutes() {
const out = [];
for (const file of fs.readdirSync(ROUTES_DIR).filter(f => f.endsWith('.js'))) {
const router = require(path.join(ROUTES_DIR, file));
for (const layer of router.stack || []) {
if (!layer.route) continue;
for (const method of Object.keys(layer.route.methods)) {
// router.route('/x').get(...).put(...) puts every method's handlers on one route, so the
// role is checked per method (admin-only methods are never campus-scoped).
const forMethod = layer.route.stack.filter(s => s.method === method || s.method === undefined).map(s => s.handle);
if (!forMethod.includes(staff) && !forMethod.includes(supervisor)) continue;
const marker = forMethod.find(h => h && h.campusScope);
out.push({ file, method: method.toUpperCase(), path: layer.route.path, marker: marker ? marker.campusScope : null });
}
}
}
return out;
}
describe('campus scope on staff and supervisor routes', () => {
const routes = staffRoutes();
test('finds the staff and supervisor routes', () => {
expect(routes.length).toBeGreaterThan(100);
});
test('every one says how it handles campus-scoped staff', () => {
const missing = routes.filter(r => !r.marker).map(r => `${r.file} ${r.method} ${r.path}`);
expect(missing).toEqual([]);
});
test('a campus-independent route gives a reason', () => {
for (const r of routes.filter(x => x.marker && x.marker.startsWith('independent'))) {
expect(r.marker.length).toBeGreaterThan('independent: '.length + 5);
}
});
});
+80
View File
@@ -0,0 +1,80 @@
const { slugify, cleanCampusInput, campusWhere, campusAudienceWhere, campusForEvent } = require('../src/utils/campuses');
describe('campus web addresses', () => {
test.each([
['Hope Centre — Henley', 'hope-centre-henley'],
[' Vaal Campus ', 'vaal-campus'],
['Ēast Rand!!', 'east-rand'],
['---', ''],
])('%s → %s', (input, slug) => expect(slugify(input)).toBe(slug));
});
describe('campus fields', () => {
test('a new campus needs a name; the slug comes from it', () => {
expect(cleanCampusInput({}).error).toMatch(/name/);
expect(cleanCampusInput({ name: 'Henley Campus' }).data).toMatchObject({ name: 'Henley Campus', slug: 'henley-campus' });
});
test('a chosen slug is cleaned', () => {
expect(cleanCampusInput({ name: 'Henley', slug: 'Henley On Klip' }).data.slug).toBe('henley-on-klip');
});
test('bad colour or email is refused; blanks become null', () => {
expect(cleanCampusInput({ name: 'A', colour: 'blue' }).error).toMatch(/colour/);
expect(cleanCampusInput({ name: 'A', contactEmail: 'nope' }).error).toMatch(/email/);
expect(cleanCampusInput({ name: 'A', colour: '#1D4ED8', address: ' ' }).data).toMatchObject({ colour: '#1d4ed8', address: null });
});
test('an update only touches what was sent', () => {
expect(cleanCampusInput({ address: '1 Church St' }, { partial: true }).data).toEqual({ address: '1 Church St' });
expect(cleanCampusInput({ name: '' }, { partial: true }).error).toMatch(/name/);
});
});
describe('campus filters on event lists', () => {
test('ignored while campuses are off, or with no filter', () => {
expect(campusWhere('c1', { enabled: false })).toEqual({});
expect(campusWhere('', { enabled: true })).toEqual({});
});
test('one campus, or only church-wide events', () => {
expect(campusWhere('c1', { enabled: true })).toEqual({ campusId: 'c1' });
expect(campusWhere('all', { enabled: true })).toEqual({ campusId: null });
});
test('the public list also shows church-wide events at every campus', () => {
expect(campusWhere('c1', { enabled: true, withAllCampuses: true })).toEqual({ OR: [{ campusId: 'c1' }, { campusId: null }] });
});
});
describe('the campus on an event', () => {
test('nothing changes while campuses are off or nothing was sent', async () => {
expect(await campusForEvent('c1', { enabled: false })).toEqual({ campusId: undefined });
expect(await campusForEvent(undefined, { enabled: true })).toEqual({ campusId: undefined });
});
test('blank or "all" means all campuses', async () => {
expect(await campusForEvent('', { enabled: true })).toEqual({ campusId: null });
expect(await campusForEvent(null, { enabled: true })).toEqual({ campusId: null });
expect(await campusForEvent('all', { enabled: true })).toEqual({ campusId: null });
});
});
describe('everyone at a campus', () => {
const now = new Date('2026-10-27T10:00:00Z');
test('home campus only, active real accounts', () => {
const where = campusAudienceWhere('c1', { now });
expect(where.isActive).toBe(true);
expect(where.OR).toEqual([{ homeCampusId: 'c1' }]);
expect(where.NOT).toEqual(expect.arrayContaining([{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }]));
});
test('with registrants: registered for that campus\'s events in the last 12 months, not cancelled', () => {
const where = campusAudienceWhere('c1', { includeRegistrants: true, now });
const reg = where.OR[1].registrations.some;
expect(reg.event).toEqual({ campusId: 'c1' });
expect(reg.status).toEqual({ not: 'cancelled' });
expect(reg.createdAt.gte.toISOString().slice(0, 10)).toBe('2025-10-27');
});
});
+151
View File
@@ -0,0 +1,151 @@
const kids = require('../src/utils/kids');
describe('pickup codes', () => {
test('are 4 characters from the alphabet without 0/O/1/I', () => {
for (let i = 0; i < 500; i++) {
const c = kids.randomCode();
expect(c).toHaveLength(4);
expect(c).toMatch(/^[A-HJ-NP-Z2-9]{4}$/);
}
});
test('typed or scanned codes are cleaned up', () => {
expect(kids.normaliseCode('k7p2')).toBe('K7P2');
expect(kids.normaliseCode(' K7 P2 ')).toBe('K7P2');
expect(kids.normaliseCode('KIDS-K7P2')).toBe('K7P2');
expect(kids.normaliseCode('k7-p2')).toBe('K7P2');
});
test('anything that cannot be a code is null', () => {
expect(kids.normaliseCode('')).toBeNull();
expect(kids.normaliseCode('K7P')).toBeNull();
expect(kids.normaliseCode('K7P22')).toBeNull();
expect(kids.normaliseCode('K0P2')).toBeNull(); // no zero
expect(kids.normaliseCode('KOP2')).toBeNull(); // no O
expect(kids.normaliseCode('K1P2')).toBeNull();
});
});
describe('ages and rooms', () => {
const on = new Date('2026-11-01T08:00:00Z');
test('age in whole years, birthday counted on the day', () => {
expect(kids.ageOn('2021-11-01', on)).toBe(5);
expect(kids.ageOn('2021-11-02', on)).toBe(4);
expect(kids.ageOn(null, on)).toBeNull();
});
const rooms = [
{ id: 'all', name: 'Everyone', minAge: null, maxAge: null, order: 0 },
{ id: 'tod', name: 'Toddlers', minAge: 1, maxAge: 3, order: 1, capacity: 2 },
{ id: 'jun', name: 'Juniors', minAge: 4, maxAge: 7, order: 2 },
];
test('fitsRoom respects the range; no age fits anywhere', () => {
expect(kids.fitsRoom(rooms[1], 2)).toBe(true);
expect(kids.fitsRoom(rooms[1], 4)).toBe(false);
expect(kids.fitsRoom(rooms[1], null)).toBe(true);
});
test('suggests the narrowest room that fits', () => {
expect(kids.suggestRoom(rooms, 2).id).toBe('tod');
expect(kids.suggestRoom(rooms, 5).id).toBe('jun');
expect(kids.suggestRoom(rooms, 11).id).toBe('all');
});
test('skips full rooms', () => {
expect(kids.suggestRoom(rooms, 2, { tod: 2 }).id).toBe('all');
});
test('without an age, the first room in order', () => {
expect(kids.suggestRoom(rooms, null).id).toBe('all');
});
test('nothing fits → null', () => {
expect(kids.suggestRoom([rooms[1]], 9)).toBeNull();
});
});
describe('room status', () => {
test('ratio: leaders needed and over ratio', () => {
expect(kids.roomStatus({ ratio: 5 }, 6, 1)).toMatchObject({ leadersNeeded: 2, overRatio: true });
expect(kids.roomStatus({ ratio: 5 }, 5, 1)).toMatchObject({ leadersNeeded: 1, overRatio: false });
expect(kids.roomStatus({ ratio: 5 }, 0, 0)).toMatchObject({ leadersNeeded: 0, overRatio: false });
});
test('no ratio is never over ratio; capacity makes it full', () => {
expect(kids.roomStatus({ capacity: 3 }, 3, 0)).toMatchObject({ full: true, overRatio: false });
expect(kids.roomStatus({}, 30, 0)).toMatchObject({ full: false, overRatio: false });
});
});
describe('sticker text', () => {
test('allergy and medical flags, kept short', () => {
expect(kids.stickerAlert('Peanuts', '')).toBe('ALLERGY: PEANUTS');
expect(kids.stickerAlert('', 'Asthma pump in bag')).toBe('MEDICAL');
expect(kids.stickerAlert('nuts', 'epipen')).toBe('ALLERGY: NUTS · MEDICAL');
expect(kids.stickerAlert('', '')).toBe('');
expect(kids.stickerAlert('a'.repeat(40), '')).toMatch(/^ALLERGY: A{24}…$/);
});
});
describe('occasions', () => {
const single = { scheduleType: 'single', startDate: '2026-11-01T07:00:00Z', endDate: '2026-11-01T10:00:00Z' };
test('a single-date event: its day, only while it is on', () => {
expect(kids.occasionFor(single, [], null, new Date('2026-11-01T06:00:00Z'))).toEqual({ occasion: '2026-11-01', sessionId: null });
expect(() => kids.occasionFor(single, [], null, new Date('2026-11-02T08:00:00Z'))).toThrow(/open on 2026-11-01/);
});
test('a multi-session event: today\'s session', () => {
const ev = { scheduleType: 'sessions', checkInWindow: 'whole_day' };
const sessions = [
{ id: 's1', startsAt: '2026-11-01T07:00:00Z', endsAt: '2026-11-01T09:00:00Z' },
{ id: 's2', startsAt: '2026-11-08T07:00:00Z', endsAt: '2026-11-08T09:00:00Z' },
];
expect(kids.occasionFor(ev, sessions, null, new Date('2026-11-08T06:30:00Z'))).toEqual({ occasion: 's2', sessionId: 's2' });
expect(() => kids.occasionFor(ev, sessions, null, new Date('2026-11-04T08:00:00Z'))).toThrow(/next one/);
});
});
describe('messages', () => {
test('check-in message has the code and rooms, nothing medical', () => {
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia', room: 'Toddlers' }, { name: 'Ben', room: 'Juniors' }] });
expect(text).toContain('Mia and Ben are checked in at Sunday Kids (Toddlers, Juniors)');
expect(text).toContain('Pickup code: K7P2');
expect(text).toContain('/dashboard/user/kids');
});
test('for an authorised adult, no link to an account', () => {
const text = kids.checkInMessage({ eventTitle: 'Sunday Kids', code: 'K7P2', children: [{ name: 'Mia' }], forParent: false });
expect(text).toContain('Mia is checked in');
expect(text).not.toContain('My account');
});
test('listNames', () => {
expect(kids.listNames(['A'])).toBe('A');
expect(kids.listNames(['A', 'B', 'C'])).toBe('A, B and C');
});
});
describe('children', () => {
test('cleanChild needs a first name and seals the notes', () => {
expect(() => kids.cleanChild({})).toThrow(/first name/);
const data = kids.cleanChild({ firstName: ' Mia ', allergies: 'Peanuts', custodyNotes: 'Not to Mr X' });
expect(data.firstName).toBe('Mia');
expect(data.allergies).toMatch(/^enc:/);
expect(data.custodyNotes).toMatch(/^enc:/);
expect(kids.childView({ id: 'c', ...data }).allergies).toBe('Peanuts');
});
test('a future date of birth is refused', () => {
expect(() => kids.cleanChild({ firstName: 'A', dateOfBirth: '2999-01-01' })).toThrow(/date of birth/);
});
test('custodyChanged only when the text changes', () => {
const existing = kids.cleanChild({ firstName: 'A', custodyNotes: 'x' });
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'x' }, existing))).toBe(false);
expect(kids.custodyChanged(existing, kids.cleanChild({ custodyNotes: 'y' }, existing))).toBe(true);
expect(kids.custodyChanged(existing, kids.cleanChild({ allergies: 'y' }, existing))).toBe(false);
});
});
+67 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-27, after **1.42.1** (and Hub 1.1.0). The plan itself is in
Last updated: 2026-10-28, after **1.45.0** (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.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
| 1.43.0 | **Campuses** (§12 part 1): Site settings → Campuses (off by default), events per campus or all campuses, public campus filter (remembered, starts on home campus) and `/campus/<slug>` pages, home campus on the profile, campus filters on Reports and the dashboards, "Everyone at a campus" in broadcasts, campus summary recipients, campus on ticket PDFs, heartbeat `campusCount`. Also adds the `hub_update_requested` audit action missing since 1.42.0 |
| 1.42.1 | Production Hub public key built in (`publicKey.js`); `HUB_PUBLIC_KEY` only needed for a local Hub |
| 1.42.0 | **Request update** instead of the deploy command: Site settings → Licence sends a signed update request to the Hub (protocol §3a), which opens an alert and emails Joshua; "Update requested on …" until the site runs that version, and the admin update banner hides meanwhile. Audit action `hub_update_requested`. Needs Hub 1.1.0 |
| 1.41.3 | Renamed Hope Events → **Cross Events** in all UI text and docs (technical identifiers unchanged, see above) |
@@ -42,7 +45,8 @@ encrypted settings unreadable or break existing installs.
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.43.0 — Multi-campus** (§12).
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.
## Working on this repo
@@ -191,6 +195,64 @@ encrypted settings unreadable or break existing installs.
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
run on a real server).
## Where campuses live (1.43.0)
- `backend/src/utils/campuses.js`: the setting (`campuses_enabled`, off by default), `campusWhere`
(list filters: one campus, `'all'` = church-wide only, `withAllCampuses` for the public list),
`campusForEvent` (what to save on an event), `campusAudienceWhere` (broadcast audience). Pure
parts are tested in `tests/campuses.test.js`.
- `controllers/campusController.js`, routes `/api/campuses/*`. The setting is changed only through
`PUT /api/campuses/settings` (it's in `PRIVATE_KEYS` in the settings controller).
- **While campuses are off, every campus field and filter is ignored** (the data stays). New code
should check `campusesEnabled()` the same way.
- Frontend: `hooks/useCampuses.ts` (one cached `GET /api/campuses` per page load),
`components/shared/CampusFilter.tsx` (`CampusFilter`, `matchesCampus`, `CampusBadge`),
`CampusAudiencePicker.tsx`, `components/admin/CampusesTab.tsx`, `components/events/EventsGrid.tsx`
(public filter, `hope_events_campus` in localStorage), `app/campus/[slug]/page.tsx`.
- e2e: the campuses and two events' campuses are **seeded** in `e2e/support/prepare-db.js` with
campuses off (so a pre-built frontend already has them); `campuses.spec.ts` turns them on and off.
## Campus-scoped staff (1.44.0) — adding a staff route
- **Every route behind `staff` or `supervisor` must carry a marker from `utils/campusScope.js`**, or
`tests/campusScopeRoutes.test.js` fails:
- `scopeEvent({ resolve, from, need, ownerOk, nameCampus })` — one event, or a record traced to
one (`RESOLVERS`: registration, ticket, ticketQr, payment, eventOption, optionVariant, bundle,
allocationSet/Group, waitlistEntry, scanLock, syncReport, section, certificate, consentRequest).
`need: 'manage'` for setup, cashup and refunds (church-wide events refused).
- `scopeBody([[field, resolver], …], { need, orphan })` — records named in the body (lists of ids
too); `orphan: 'deny'` when there must be an event (payments).
- `scopeList` — the controller filters with `listWhere(req, path)` / `withScope(where, extra)`
(never spread a scope `where` over one that has its own OR) or checks itself.
- `unscopedOnly`, `campusIndependent('reason')`, `scopeBroadcast({ freeformField })`.
- Routes open to the owner *and* staff (no role middleware, so the test can't see them) need
`scopeEvent({ …, ownerOk: true })` by hand — registrations, payments, tickets, consent requests
and coupon validation have it.
- Rules: admins never scoped; scopes ignored while campuses are off; another campus → 404, except
the scanner (403 naming the campus). Tested in `tests/campusScope.test.js` and e2e
`campus-scoped.spec.ts` (staff and supervisor scoped to E2E South; a seeded North ticket).
## Where kids check-in lives (1.45.0)
- `backend/src/utils/kids.js` — everything: codes (`randomCode`, `normaliseCode`), occasions
(`occasionFor`: today's session, or the day), room suggestion and status, families and children
(allergies/medical/custody encrypted with `utils/encryption.js`), `checkIn` (code per family per
occasion via `KidsPickupCode`, a free place on `Event.kidsOptionId` through `ticketForChild`, a
`TicketUsage` per check-in), `checkOut` / `overrideCheckOut`, messages, `board`, duty, offline
snapshot and `syncOffline`, and the privacy parts (`removeFromFamily`, `exportFamily`,
`runKidsRetention`). Pure parts tested in `tests/kids.test.js`.
- `controllers/kidsController.js` + `routes/kidsRoutes.js` (`/api/kids`); campus resolvers
`kidsCheckIn` and `kidsAlert` in `utils/campusScope.js`.
- Rooms are `AllocationGroup`s of `Event.kidsRoomSetId` (with `minAge`, `maxAge`, `ratio`).
Event ids on `Event` (`kidsOptionId`, `kidsRoomSetId`) are plain strings, checked in code.
- Kids check-in isn't blocked in licence read-only mode, on purpose: it's like scanning tickets,
and children's safety at pickup comes first.
- Frontend: `lib/kids.ts` (types, room suggestion, offline copy in `hope_events_kids_offline_<eventId>`),
`components/kids/*` (`KidsEventPicker`, `ChildForm`, `RoomBoard`, `StickerPanel`), pages
`app/dashboard/staff/kids` (desk), `staff/kids/rooms`, `supervisor/kids` (setup), `user/kids`.
- e2e: `kids.spec.ts` on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk
checks in, parent sees the code, wrong code then the right one, room alert).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
@@ -209,3 +271,6 @@ encrypted settings unreadable or break existing installs.
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
- Kids check-in: the offline desk is tested at the API level (sync, idempotency, code
reservation), not in the browser. A family's two parents share one family; linking a second
parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.
+75 -32
View File
@@ -27,8 +27,12 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
everything after moves up by one more (2026-10-27).
**Next: 1.43.0 Campuses.**
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
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.**
**Repositories:**
@@ -44,10 +48,10 @@ everything after moves up by one more (2026-10-27).
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
4. [Kids check-in (digital pickup codes)](#4-kids-check-in-digital-pickup-codes)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
6. [Event series and cloning](#6-event-series-and-cloning)
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
@@ -391,37 +395,49 @@ self-service privacy tools.
---
## 4. Kids check-in with security labels
## 4. Kids check-in (digital pickup codes)
A secure check-in/check-out flow for children's ministry and youth events.
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
A secure check-in/check-out flow for children's ministry, run from a **staff desk**.
**Builds on sessions (§1), attendees, allocation groups and campuses (§12).** Fully digital —
no label printers (2026-10-28).
**Who:** Parents check in children (self-service kiosk or staff desk).
Kids-ministry staff run rooms. Supervisors set up rooms.
**Who:** Kids-ministry staff at the check-in desk and in the rooms. Supervisors set up rooms.
Parents manage their family's children in My account and see pickup codes on their phone.
### How it works
- Parent finds their family (phone number / QR from My account) and ticks the
children being dropped off. New children can be added on the spot.
- The system prints **two labels**:
- **Child label:** name, room, allergy/medical icons, short pickup code.
- **Parent label:** the same pickup code.
- **Check-out** requires scanning or typing the matching code. A mismatch
alerts the room leader.
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
1 leader per 5 toddlers) and live room counts.
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
parent ("Please come to the Toddlers room").
- Attendance per child per session feeds the attendance register.
- **Families and children are kept between events** (weekly kids church): a family has one or
more parent accounts, its children (name, date of birth, allergies, medical notes, custody
notes — the last three encrypted) and **authorised adults** (name, phone, relationship — no
account needed). Parents manage them under My account → Kids; staff can find a family by
phone, name or the parent's QR code and add a family or child on the spot.
- An event gets a **Kids check-in** switch (single events or one session per Sunday). The first
check-in makes a free registration under the parent with an attendee and ticket per child, and
each check-in writes a `TicketUsage`, so the attendance register, reports and campus scoping work
as they do for tickets.
- Each check-in gets a **pickup code** (4 characters, no 0/O/1/I, unique per event and day). The
parent sees it live on their phone (code + QR) and gets it by WhatsApp/email; it can also be
sent to an authorised adult. **Anyone with the code can collect** the child.
- **"Write on the sticker" panel** on the desk screen: first name, room, code and a short alert
(ALLERGY / MEDICAL) in large print, for volunteers to copy onto a blank sticker. The full
details stay on screen.
- **Rooms** are a "Kids rooms" allocation set: each room has an age range, capacity and a
**ratio** (children per leader). The room is suggested from the child's age. Room screens show
live cards (name, code, allergy/medical icons, custody notes) and counts: children in, leaders
on duty ("I'm on duty"), over-ratio and full warnings.
- **Check-out** by scanning the parent's QR or typing the code; the leader records who collected.
A wrong code alerts the room and is logged; "Override" needs a reason.
- **Page a parent:** "Call parent" → WhatsApp (email fallback) and a notice on the parent's page.
### Plumbing / edge cases
- Label printing via browser print with label-size CSS (Dymo / Brother label
printers, 62 mm and 89×36 mm presets); PDF fallback.
- Allergies and medical info come from the consent/medical fields; respect
the medical-info deletion rules.
- Custody notes ("Do not release to …") visible to room leaders only.
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
- Medical and custody notes: room leaders, supervisors and admins only; never in messages;
room lists showing them are written to the access log.
- POPIA: families and children in `anonymiseUser` / `exportUserData`; retention rule — a child's
profile is deleted after **6 months** without a check-in; readiness flags children's data.
- Offline: the desk caches the event's families and checks in offline (device-prefixed codes,
synced later). Check-out offline only for children checked in on the same device.
- Audit log: overrides, wrong codes and custody-note changes.
---
@@ -456,6 +472,8 @@ Plan who serves where, for an event or each session.
## 7. Apple and Google Wallet tickets
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
ticket, in email and in My Events.
- Pass shows event, date (next session for multi-day), location, holder name,
@@ -580,6 +598,28 @@ install. It ships in **two releases** (2026-10-27):
the read-only route test), so new endpoints can't skip the check.
- The heartbeat's `features.campuses` / `stats.campuses` report the real setting
(hard-coded `false` until 1.43.0).
- **How each kind of endpoint is checked:** event id in the URL → a route middleware
(`scopeEvent`); a record that belongs to an event (registration, ticket/QR, waitlist
entry, option, bundle, allocation set…) → traced back to its event, same check;
lists across events → the controller adds the scope `where` (route marked so the
test knows); an event named in the body → checked before saving; nothing to do with
an event → allowed or unscoped-only, listed with a reason.
- **Rules (decided 2026-10-27):**
- Admins are never scoped. Scoped supervisors and staff see their campuses' events
plus "All campuses" events.
- "All campuses" events: scoped staff can view, scan, take payments at the door and
do manual registrations; event setup, options, bundles, cashup and refunds stay with
admins and unscoped supervisors.
- Site banner, payment-provider reconciliation and donations not linked to an event:
admins and unscoped supervisors only. Ad-hoc broadcasts: scoped supervisors only to
their campuses' audiences and their events' attendees. Coupons: only for their
campuses' events.
- Another campus's records answer "not found" (nothing leaks); the scanner instead
says which campus the ticket is for.
- While campuses are off, scopes are ignored (everyone sees everything); the Campuses
tab warns which staff are limited before turning campuses off.
- Scopes are set by admins in Admin → Users ("Limit to campuses"); changes are audited
(`user_campus_scope_changed`).
### Data model (sketch)
@@ -909,11 +949,11 @@ income justify the hardware:
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 – 1.44.0 | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 – 1.46.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 – 1.49.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.50.0 – 1.52.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.53.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 – 1.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -960,8 +1000,11 @@ feature.
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, **on by default**, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
| — | Product name | **Cross Events** (was Hope Events), Hub: **Cross Events Hub**. UI text and docs renamed in 1.41.3; technical identifiers (`hope-events` repo, storage keys, encryption salt, migrations, PM2/nginx names) stay, so installs and sign-ins keep working (2026-10-27) |
| — | Campus-scoped staff (1.44.0) | Scoped staff: their campuses' events plus "All campuses" events, which they can view, scan, take door payments and do manual registrations for, but not set up, cash up or refund. Banner, provider reconciliation and unlinked donations: unscoped only; broadcasts limited to their campuses' audiences and their events' attendees; coupons only for their events. Other campuses' records answer "not found" except the scanner, which names the campus. Scopes ignored while campuses are off, with a warning (2026-10-27) |
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | 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
+2 -1
View File
@@ -96,7 +96,8 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"checkIns": { "last30d": 96 },
"messages": { "email30d": 610, "whatsapp30d": 233 },
"paymentProviders": ["yoco", "payfast"],
"campuses": false,
"campuses": false, // Site settings → Campuses on/off
"campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+)
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
}
}
+52
View File
@@ -0,0 +1,52 @@
import { test, expect, seed, api, loginAs, API } from "./fixtures";
// Campus-scoped staff (1.44.0): the seeded staff member and supervisor are limited to E2E South
// for these tests; afterwards the limits are cleared and campuses turned off again.
test.describe("campus-scoped staff", () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: true });
for (const role of ["staff", "supervisor"] as const) {
await api(page, "admin", "PUT", `/api/users/${seed().users[role].id}`, { scopedCampusIds: [seed().campuses.south.id] });
}
await page.close();
});
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
for (const role of ["staff", "supervisor"] as const) {
await api(page, "admin", "PUT", `/api/users/${seed().users[role].id}`, { scopedCampusIds: [] });
}
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: false });
await page.close();
});
test("the scanner refuses another campus's ticket and says which campus", async ({ page }) => {
const scan = (qr: string) => page.request.get(`${API}/api/tickets/scan-preview/${encodeURIComponent(qr)}`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
const north = await scan(seed().northTicketQr);
expect(north.status()).toBe(403);
expect((await north.json()).message).toBe("This ticket is for an event at E2E North.");
// The course is for all campuses, so it scans.
expect((await scan(seed().course.qrCode)).status()).toBe(200);
});
test("a scoped supervisor sees their campus and church-wide events, and can't edit church-wide ones", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByText("You work with E2E South, and events for all campuses.")).toBeVisible();
await page.goto("/dashboard/supervisor/events");
await expect(page.getByText("E2E Paid Conference")).toBeVisible(); // South
await expect(page.getByText("E2E Alpha Course")).toBeVisible(); // all campuses
await expect(page.getByText("E2E Free Evening")).toBeHidden(); // North
await page.getByText("E2E Alpha Course").click();
await expect(page.getByText(/This is an event for all campuses — only admins and supervisors for every campus can change it/)).toBeVisible();
await expect(page.getByRole("button", { name: "Save Changes" })).toBeDisabled();
});
test("another campus's registrations are not found for a scoped supervisor", async ({ page }) => {
const res = await page.request.get(`${API}/api/registrations/event/${seed().events.free}`, { headers: { Authorization: `Bearer ${seed().users.supervisor.token}` } });
expect(res.status()).toBe(404);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { test, expect, seed, api, API } from "./fixtures";
// Multi-campus. The campuses and the two events' campuses are seeded (e2e/support/prepare-db.js)
// with campuses off; these tests turn them on, and off again afterwards so the other specs see a
// single-campus church.
test.describe("campuses", () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: true });
await page.close();
});
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
await api(page, "admin", "PUT", "/api/campuses/settings", { enabled: false });
await page.close();
});
test("the events page filters by campus and keeps church-wide events", async ({ page }) => {
await page.goto("/events");
await expect(page.getByText("E2E Paid Conference")).toBeVisible();
await page.getByLabel("Campus").selectOption({ label: "E2E North" });
await expect(page.getByText("E2E Free Evening")).toBeVisible(); // North
await expect(page.getByText("E2E Alpha Course")).toBeVisible(); // all campuses
await expect(page.getByText("E2E Paid Conference")).toBeHidden(); // South
// The choice is remembered.
await page.reload();
await expect(page.getByLabel("Campus")).toHaveValue(seed().campuses.north.id);
await expect(page.getByText("E2E Paid Conference")).toBeHidden();
});
test("a campus has its own page", async ({ page }) => {
await page.goto(`/campus/${seed().campuses.north.slug}`);
await expect(page.getByRole("heading", { name: "E2E North" })).toBeVisible();
await expect(page.getByText("1 North Road")).toBeVisible();
await expect(page.getByText("E2E Free Evening")).toBeVisible();
await expect(page.getByText("E2E Paid Conference")).toBeHidden();
});
test("a campus with events can't be deleted", async ({ page }) => {
const res = await page.request.delete(`${API}/api/campuses/${seed().campuses.north.id}`, { headers: { Authorization: `Bearer ${seed().users.admin.token}` } });
expect(res.status()).toBe(409);
expect((await res.json()).code).toBe("CAMPUS_HAS_EVENTS");
});
});
+3 -1
View File
@@ -5,7 +5,9 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string };
campuses: Record<"north" | "south", { id: string; slug: string }>;
northTicketQr: string;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, seed, loginAs } from "./fixtures";
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
// the real one checks the child out. "E2E Kids Church" is seeded hidden and on today.
test.describe.serial("kids check-in", () => {
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/kids");
await page.getByRole("combobox").first().selectOption(seed().events.kids);
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
await page.getByLabel("Room name").last().fill("Little Lambs");
await page.getByLabel("Youngest age").last().fill("0");
await page.getByLabel("Oldest age").last().fill("5");
await page.getByLabel("Children per leader").last().fill("4");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByLabel("Room name").first()).toHaveValue("Little Lambs");
await page.getByRole("button", { name: "Switch on" }).click();
await expect(page.getByText("Kids check-in is on")).toBeVisible();
});
test("a parent adds their family and a child", async ({ page }) => {
await loginAs(page, "user");
await page.goto("/dashboard/user/kids");
await page.getByRole("button", { name: "Add family" }).click();
await page.getByRole("button", { name: "Add a child" }).click();
await page.getByLabel("First name *").fill("Lulu");
const dob = new Date();
dob.setFullYear(dob.getFullYear() - 3);
await page.getByLabel("Date of birth").fill(dob.toISOString().slice(0, 10));
await page.getByLabel("Allergies").fill("Peanuts");
await page.getByRole("button", { name: "Add child" }).click();
await expect(page.getByText("Allergies: Peanuts")).toBeVisible();
await expect(page.getByAltText("Family QR code")).toBeVisible();
});
test("the desk checks the child in, the parent sees the code, and only the right code checks out", async ({ page, browser }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/kids");
await page.getByLabel("Event").selectOption(seed().events.kids);
await page.getByLabel("Find a family").fill("Lulu");
await page.getByRole("button", { name: "Search" }).click();
await page.getByRole("button", { name: /Lulu/ }).click();
// The room is suggested from her age.
await expect(page.getByLabel("Room for Lulu")).toHaveValue(/.+/);
await page.getByRole("button", { name: "Check in", exact: true }).last().click();
const code = (await page.getByTestId("desk-code").textContent())!.trim();
expect(code).toMatch(/^[A-HJ-NP-Z2-9]{4}$/);
const sticker = page.getByTestId("sticker");
await expect(sticker).toContainText("Lulu");
await expect(sticker).toContainText("Little Lambs");
await expect(sticker).toContainText("ALLERGY: PEANUTS");
// The parent's phone shows the same code.
const parent = await browser.newPage();
await parent.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
await loginAs(parent, "user");
await parent.goto("/dashboard/user/kids");
await expect(parent.getByTestId("pickup-code")).toHaveText(code);
await parent.close();
// A wrong code is refused.
await page.getByRole("button", { name: "Check out" }).first().click();
const wrong = code === "ZZZZ" ? "YYYY" : "ZZZZ";
await page.getByLabel("Pickup code").fill(wrong);
await page.getByRole("button", { name: "Find" }).click();
await expect(page.getByRole("alert").filter({ hasText: "doesn't match any child" })).toBeVisible();
// The real one checks her out.
await page.getByLabel("Pickup code").fill(code.toLowerCase());
await page.getByRole("button", { name: "Find" }).click();
await expect(page.getByText("Lulu", { exact: false }).first()).toBeVisible();
await page.getByRole("button", { name: "Check out" }).last().click();
await expect(page.getByRole("status")).toContainText("Collected: Lulu");
});
test("the room screen shows the wrong-code alert", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/kids/rooms");
await expect(page.getByRole("alert").filter({ hasText: "Wrong pickup code" }).first()).toBeVisible();
await page.getByRole("button", { name: "Dealt with" }).first().click();
await expect(page.getByRole("status")).toContainText("Alert cleared");
});
});
+29 -1
View File
@@ -68,15 +68,29 @@ async function seed(url) {
return d;
};
// Multi-campus: two campuses with campuses switched off (campuses.spec.ts turns them on).
// Seeded here rather than in the test so pages built before the tests already carry them.
const north = await prisma.campus.create({ data: { name: 'E2E North', slug: 'e2e-north', address: '1 North Road', order: 0 } });
const south = await prisma.campus.create({ data: { name: 'E2E South', slug: 'e2e-south', order: 1 } });
const free = await prisma.event.create({
data: {
campusId: north.id,
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
// Kids check-in (kids.spec.ts): on today, hidden so it doesn't change the public lists.
const kids = await prisma.event.create({
data: {
title: 'E2E Kids Church', description: 'Kids check-in for the tests.', startDate: new Date(Date.now() - 60 * 60 * 1000), endDate: new Date(Date.now() + 6 * 60 * 60 * 1000),
price: 0, isHidden: true, createdById: users.supervisor.id,
},
});
const paid = await prisma.event.create({
data: {
campusId: south.id,
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
@@ -153,9 +167,23 @@ async function seed(url) {
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
// A ticket for the free evening (North campus), for the campus-scoped scanner test. The admin
// holds it so it never collides with the registration tests (which use Thandi).
const freeOption = await prisma.eventOption.findFirst({ where: { eventId: free.id } });
const northReg = await prisma.registration.create({
data: {
userId: users.admin.id, eventId: free.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: freeOption.id, quantity: 1, priceSnapshot: 0, tranches: { create: [{ quantity: 1, priceSnapshot: 0 }] } }] },
},
});
await generateTicketsForRegistration(northReg.id);
const northTicket = await prisma.ticket.findFirst({ where: { registrationOption: { registrationId: northReg.id } } });
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id },
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
northTicketQr: northTicket.qrCode,
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.42.1",
"version": "1.45.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.42.1",
"version": "1.45.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.42.1",
"version": "1.45.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+93
View File
@@ -0,0 +1,93 @@
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { ApiImage } from "@/components/shared/ApiImage";
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
import { mapsSearchUrl } from "@/lib/maps";
import { CalendarX, MapPin, Mail, Phone, User, ArrowLeft } from "lucide-react";
import type { Campus } from "@/hooks/useCampuses";
export const revalidate = 60;
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
async function loadCampus(slug: string): Promise<Campus | null> {
try {
return await apiFetch<Campus>(`/api/campuses/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
// 404: no such campus, archived, or campuses are off.
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const campus = await loadCampus(slug);
if (!campus) return {};
return {
title: `${campus.name} — events`,
description: `Upcoming events at ${campus.name}${campus.address ? `, ${campus.address}` : ""}.`,
alternates: { canonical: `/campus/${campus.slug}` },
};
}
/** A campus's own page: its details and upcoming events (its own plus church-wide ones). */
export default async function CampusPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const campus = await loadCampus(slug);
if (!campus) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
.sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
<Link href="/events" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> All events
</Link>
<div className="border rounded-2xl p-6 mb-8 bg-white shadow-sm" style={campus.colour ? { borderTop: `4px solid ${campus.colour}` } : undefined}>
<div className="flex items-start gap-4">
{campus.logoUrl && <ApiImage src={campus.logoUrl} alt="" className="w-16 h-16 object-contain rounded-lg shrink-0" />}
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{campus.name}</h1>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{campus.address && (
<p className="flex items-center gap-1.5">
<MapPin className="w-4 h-4 shrink-0" />
<a href={mapsSearchUrl(campus.address)} target="_blank" rel="noopener noreferrer" className="hover:underline">{campus.address}</a>
</p>
)}
{campus.contactName && <p className="flex items-center gap-1.5"><User className="w-4 h-4 shrink-0" /> {campus.contactName}</p>}
{campus.contactEmail && <p className="flex items-center gap-1.5"><Mail className="w-4 h-4 shrink-0" /> <a href={`mailto:${campus.contactEmail}`} className="hover:underline">{campus.contactEmail}</a></p>}
{campus.contactPhone && <p className="flex items-center gap-1.5"><Phone className="w-4 h-4 shrink-0" /> <a href={`tel:${campus.contactPhone}`} className="hover:underline">{campus.contactPhone}</a></p>}
</div>
</div>
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-1">Upcoming events</h2>
<p className="text-sm text-gray-500 mb-6">Events at {campus.name}, and church-wide events for every campus.</p>
{upcoming.length === 0 ? (
<div className="text-center py-16 border rounded-xl bg-gray-50">
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No upcoming events at {campus.name} right now — check back soon.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{upcoming.map(event => <EventCard key={event.id} event={event} />)}
</div>
)}
</main>
<Footer />
</div>
);
}
@@ -97,6 +97,18 @@ const ACTION_LABELS: Record<string, string> = {
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Cross Events Hub",
hub_update_requested: "Asked the Hub for an update",
campuses_toggled: "Campuses turned on/off",
campus_created: "Campus added",
campus_updated: "Campus changed",
campus_archived: "Campus archived/restored",
campus_deleted: "Campus deleted",
event_campus_changed: "Event moved to another campus",
user_campus_scope_changed: "Staff member limited to campuses",
kids_settings_updated: "Kids check-in settings changed",
kids_checkout_override: "Child checked out without the pickup code",
kids_wrong_code: "Wrong pickup code entered",
kids_custody_notes_changed: "Child's custody notes changed",
kids_offline_sync: "Offline kids desk synced",
};
interface AuditLogEntry {
+9 -2
View File
@@ -9,12 +9,14 @@ 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,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -29,6 +31,8 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/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 },
@@ -77,6 +81,7 @@ export default function AdminDashboardPage() {
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
const [campus, setCampus] = useState("");
const loadStats = useCallback(async () => {
if (!token) return;
@@ -85,7 +90,8 @@ export default function AdminDashboardPage() {
if (isFirstLoad) setLoadingStats(true);
const [dash, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
@@ -95,7 +101,7 @@ export default function AdminDashboardPage() {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
}, [token, setPaymentStats, setOverview]);
}, [token, campus, setPaymentStats, setOverview]);
useEffect(() => {
if (!token) return;
@@ -118,6 +124,7 @@ export default function AdminDashboardPage() {
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
</div>
{!isAdmin && (
@@ -7,22 +7,24 @@ import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { PaymentsTab } from "@/components/admin/PaymentsTab";
import { LicenceTab } from "@/components/admin/LicenceTab";
import { CampusesTab } from "@/components/admin/CampusesTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
{ id: "campuses", label: "Campuses", icon: MapPinned },
{ id: "branding", label: "Branding", icon: Palette },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "email", label: "Email", icon: Mail },
@@ -716,6 +718,7 @@ function SiteSettingsPageInner() {
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
</div>
{activeTab === "branding" && (
@@ -11,6 +11,7 @@ import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleB
import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
interface UserItem {
id: string;
@@ -142,14 +143,26 @@ export default function AdminUsersPage() {
}
};
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
const cancelEdit = () => { setEditingId(null); setEditData({}); };
// Campus-scoped staff: the campuses a supervisor/staff member is limited to (loaded per user;
// null until loaded, so an unloaded list is never saved over the real one).
const campusInfo = useCampuses();
const [scope, setScope] = useState<{ id: string; name: string }[] | null>(null);
const startEdit = (u: UserItem) => {
setEditingId(u.id); setEditData({ ...u, password: "" }); setScope(null);
apiFetch<{ scopedCampuses?: { id: string; name: string }[] }>(`/api/users/${encodeURIComponent(u.id)}`, { authToken: token! })
.then(full => setScope(full.scopedCampuses || []))
.catch(() => setScope(null));
};
const cancelEdit = () => { setEditingId(null); setEditData({}); setScope(null); };
const canBeScoped = editData.role === "staff" || editData.role === "supervisor";
// Active campuses, plus any archived one this person is still limited to.
const scopeChoices = [...campusInfo.campuses.map(c => ({ id: c.id, name: c.name })), ...(scope || []).filter(s => !campusInfo.campuses.some(c => c.id === s.id)).map(s => ({ ...s, name: `${s.name} (archived)` }))];
const saveEdit = async () => {
if (!editingId) return;
try {
setSaving(true);
const payload: Partial<UserItem> & { password?: string } = {
const payload: Partial<UserItem> & { password?: string; scopedCampusIds?: string[] } = {
name: editData.name,
email: editData.email,
role: editData.role,
@@ -160,6 +173,7 @@ export default function AdminUsersPage() {
if (editData.password && editData.password.trim().length > 0) {
payload.password = editData.password.trim();
}
if (campusInfo.enabled && canBeScoped && scope) payload.scopedCampusIds = scope.map(c => c.id);
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
});
@@ -518,6 +532,32 @@ export default function AdminUsersPage() {
</label>
</div>
</div>
{campusInfo.enabled && canBeScoped && (
<fieldset className="border rounded p-3">
<legend className="text-xs text-gray-600 px-1">Limit to campuses</legend>
{scope === null ? <p className="text-xs text-gray-400">Loading…</p> : (
<>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{scopeChoices.map(c => (
<label key={c.id} className="flex items-center gap-1.5 text-sm">
<input
type="checkbox"
checked={scope.some(s => s.id === c.id)}
onChange={e => setScope(prev => e.target.checked ? [...(prev || []), c] : (prev || []).filter(s => s.id !== c.id))}
/>
{c.name}
</label>
))}
</div>
<p className="text-[11px] text-gray-500 mt-1">
{scope.length === 0
? "Not limited — sees every campus."
: "Sees only these campuses' events plus events for all campuses. Can't set up, cash up or refund events for all campuses, or use the site banner and payment reconciliation."}
</p>
</>
)}
</fieldset>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
@@ -0,0 +1,569 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, ApiError } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, CloudOff, LogIn, LogOut, QrCode, RefreshCw, Search, UserPlus, Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { QRScanner } from "@/components/qr/QRScanner";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard } from "@/components/kids/RoomBoard";
import { StickerPanel } from "@/components/kids/StickerPanel";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import {
FAMILY_QR_PREFIX, QR_PREFIX, deviceId, loadOffline, offlineCheckIn, saveOffline, stickerAlert, suggestRoom,
type CheckInResult, type KidsBoard, type KidsFamily, type OfflineState,
} from "@/lib/kids";
type Tab = "in" | "out" | "rooms";
type Msg = { type: "ok" | "err"; text: string } | null;
type CodeLookup = {
code: string | null; found: boolean;
family?: { name: string; parents: { userId: string; name: string; phone: string | null }[]; adults: { id: string; name: string; relationship: string | null }[] } | null;
children: { id: string; name: string; room: { id: string; name: string } | null; checkedOutAt: string | null; collectedBy: string | null }[];
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const tabCls = (on: boolean) => `px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-1.5 ${on ? "bg-brand-600 text-white" : "bg-white border text-gray-700"}`;
/** The kids check-in desk: find the family, check children in (pickup code + sticker panel), check them out by code. */
export default function KidsDeskPage() {
const { token, user } = useAuth();
const isSupervisor = user?.role === "admin" || user?.role === "supervisor";
const [eventId, setEventId] = useState("");
const [sessionId, setSessionId] = useState("");
const [tab, setTab] = useState<Tab>("in");
const [board, setBoard] = useState<KidsBoard | null>(null);
const [boardError, setBoardError] = useState<string | null>(null);
const [msg, setMsg] = useDismissingState<Msg>(null, 9000);
const [busy, setBusy] = useState(false);
// Offline
const [offline, setOffline] = useState<OfflineState | null>(null);
const [working, setWorking] = useState<"online" | "offline">("online");
// Check in
const [q, setQ] = useState("");
const [results, setResults] = useState<KidsFamily[] | null>(null);
const [family, setFamily] = useState<KidsFamily | null>(null);
const [picked, setPicked] = useState<Record<string, string>>({}); // childId → roomId
const [sendTo, setSendTo] = useState("");
const [result, setResult] = useState<CheckInResult | null>(null);
const [scanning, setScanning] = useState(false);
const [newFamily, setNewFamily] = useState<{ name: string; parentName: string; phone: string; email: string } | null>(null);
const [addingChild, setAddingChild] = useState(false);
// Check out
const [code, setCode] = useState("");
const [lookup, setLookup] = useState<CodeLookup | null>(null);
const [outPicked, setOutPicked] = useState<Record<string, boolean>>({});
const [collectedBy, setCollectedBy] = useState("");
const [wrongCode, setWrongCode] = useState<string | null>(null);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const loadBoard = useCallback(async (log = false) => {
if (!token || !eventId || working === "offline") return;
try {
const data = await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token });
setBoard(data);
setBoardError(null);
} catch (e) {
setBoard(null);
setBoardError(errorMessage(e) || "Could not load the desk");
}
}, [token, eventId, qs, working]);
useEffect(() => {
setBoard(null);
setFamily(null);
setResult(null);
setOffline(eventId ? loadOffline(eventId) : null);
loadBoard(true);
}, [eventId, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
useVisiblePolling(() => { loadBoard(); }, 10000, !!token && !!eventId && working === "online");
// Go offline by itself when the browser loses its connection (if an offline list is ready).
useEffect(() => {
const down = () => { if (loadOffline(eventId)) setWorking("offline"); };
window.addEventListener("offline", down);
return () => window.removeEventListener("offline", down);
}, [eventId]);
const rooms = useMemo(() => (working === "offline" ? offline?.snapshot.rooms : board?.rooms) || [], [working, offline, board]);
const inNow = useMemo(() => {
const m = new Map<string, string>(); // childId → room name
const list = working === "offline" ? offline?.snapshot.checkIns : board?.checkIns;
for (const c of list || []) if (!c.checkedOutAt && c.childId) m.set(c.childId, rooms.find(r => r.id === c.roomId)?.name || "");
if (working === "offline") for (const c of offline?.checkIns || []) if (!offline!.checkOuts.some(o => o.clientId === c.clientId)) m.set(c.childId, c.roomName);
return m;
}, [working, offline, board, rooms]);
const fail = (e: unknown, fallback: string) => {
// A dropped connection with an offline list ready: switch over instead of failing.
if (!(e instanceof ApiError) && loadOffline(eventId)) {
setWorking("offline");
setOffline(loadOffline(eventId));
setMsg({ type: "err", text: "No connection — switched to the offline list. Try that again." });
return;
}
setMsg({ type: "err", text: errorMessage(e) || fallback });
};
// ─── Families ───────────────────────────────────────────────────────────────
const chooseFamily = (f: KidsFamily) => {
setFamily(f);
setResults(null);
setResult(null);
setSendTo("");
setAddingChild(false);
const extra: Record<string, number> = {};
const next: Record<string, string> = {};
for (const c of f.children) {
if (inNow.has(c.id)) continue;
const room = suggestRoom(rooms, c.age, extra);
if (room) { next[c.id] = room.id; extra[room.id] = (extra[room.id] || 0) + 1; }
}
setPicked(next);
};
const search = async (e?: React.FormEvent) => {
e?.preventDefault();
const term = q.trim();
if (term.length < 2) return;
if (working === "offline") {
const t = term.toLowerCase(), digits = term.replace(/\D/g, "");
setResults((offline?.snapshot.families || []).filter(f =>
f.name.toLowerCase().includes(t)
|| f.children.some(c => c.name.toLowerCase().includes(t))
|| f.parents.some(p => p.name.toLowerCase().includes(t) || (digits.length >= 6 && (p.phone || "").replace(/\D/g, "").slice(-9).includes(digits.slice(-9))))).slice(0, 20));
return;
}
try {
setResults(await apiFetch<KidsFamily[]>(`/api/kids/events/${eventId}/families?q=${encodeURIComponent(term)}`, { authToken: token }));
} catch (err) {
fail(err, "Search failed");
}
};
const onScan = async (text: string) => {
setScanning(false);
const t = text.trim();
if (t.toUpperCase().startsWith(QR_PREFIX)) {
setTab("out");
setCode(t.slice(QR_PREFIX.length));
lookUpCode(t.slice(QR_PREFIX.length));
return;
}
if (!t.toUpperCase().startsWith(FAMILY_QR_PREFIX)) { setMsg({ type: "err", text: "That isn't a family or pickup QR code." }); return; }
if (working === "offline") { setMsg({ type: "err", text: "Family QR codes need a connection — search by name or phone instead." }); return; }
try {
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/qr/${encodeURIComponent(t.slice(FAMILY_QR_PREFIX.length))}`, { authToken: token }));
} catch (err) {
fail(err, "Family not found");
}
};
const createFamily = async (child: ChildInput) => {
if (!newFamily) return;
setBusy(true);
try {
const f = await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families`, {
method: "POST", authToken: token,
body: { name: newFamily.name, parent: { name: newFamily.parentName, phone: newFamily.phone, email: newFamily.email }, children: [child] },
});
setNewFamily(null);
chooseFamily(f);
setMsg({ type: "ok", text: `${f.name} added.` });
} catch (err) {
fail(err, "Could not add the family");
} finally {
setBusy(false);
}
};
const addChild = async (child: ChildInput) => {
if (!family) return;
setBusy(true);
try {
await apiFetch(`/api/kids/events/${eventId}/families/${family.id}/children`, { method: "POST", authToken: token, body: child });
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/${family.id}`, { authToken: token }));
setMsg({ type: "ok", text: `${child.firstName} added.` });
} catch (err) {
fail(err, "Could not add the child");
} finally {
setBusy(false);
}
};
// ─── Check in ───────────────────────────────────────────────────────────────
const doCheckIn = async () => {
if (!family) return;
const children = Object.entries(picked).filter(([, roomId]) => roomId).map(([childId, roomId]) => ({ childId, roomId }));
if (!children.length) { setMsg({ type: "err", text: "Tick the children to check in." }); return; }
if (working === "offline" && offline) {
try {
const picks = children.map(p => ({ child: family.children.find(c => c.id === p.childId)!, room: rooms.find(r => r.id === p.roomId)! }));
const out = offlineCheckIn(offline, family, picks);
setOffline(out.state);
saveOffline(eventId, out.state);
setResult({
code: out.code, qr: QR_PREFIX + out.code, occasion: offline.snapshot.occasion, family: { id: family.id, name: family.name },
checkIns: picks.map(p => ({ id: p.child.id, name: p.child.name, firstName: p.child.firstName, room: { id: p.room.id, name: p.room.name }, already: false, sticker: stickerAlert(p.child.allergies, p.child.hasMedical) })),
});
setMsg({ type: "ok", text: "Checked in on this device — tell the parent the code (no message is sent while offline)." });
} catch (err) {
setMsg({ type: "err", text: errorMessage(err) || "Could not check in offline" });
}
return;
}
setBusy(true);
try {
const r = await apiFetch<CheckInResult>(`/api/kids/events/${eventId}/check-in`, {
method: "POST", authToken: token, body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
});
setResult(r);
loadBoard();
} catch (err) {
fail(err, "Check-in failed");
} finally {
setBusy(false);
}
};
// ─── Check out ──────────────────────────────────────────────────────────────
const offlineLookup = (raw: string): CodeLookup => {
const c = raw.trim().toUpperCase().replace(/[\s-]/g, "");
const mine = (offline?.checkIns || []).filter(x => x.code === c);
const fam = offline?.snapshot.families.find(f => f.id === mine[0]?.familyId);
return {
code: c, found: mine.length > 0,
family: fam ? { name: fam.name, parents: fam.parents, adults: fam.adults } : null,
children: mine.map(x => {
const o = offline!.checkOuts.find(k => k.clientId === x.clientId);
return { id: x.clientId, name: x.name, room: { id: x.roomId, name: x.roomName }, checkedOutAt: o?.at || null, collectedBy: o?.collectedBy || null };
}),
};
};
const lookUpCode = async (raw = code) => {
setWrongCode(null);
setLookup(null);
if (!raw.trim()) return;
let l: CodeLookup;
if (working === "offline") {
l = offlineLookup(raw);
if (!l.found) {
setWrongCode(offline?.snapshot.checkIns.some(c => c.code === raw.trim().toUpperCase())
? "This code was given out before going offline. Check these children out on a connected device, or use \"Without code\" there."
: "That code doesn't match any child checked in on this device.");
return;
}
} else {
try {
l = await apiFetch<CodeLookup>(`/api/kids/events/${eventId}/code/${encodeURIComponent(raw.trim())}${qs}`, { authToken: token });
} catch (err) {
fail(err, "Lookup failed");
return;
}
if (!l.found) {
// Record the wrong code (alerts the rooms) through the check-out call.
try {
await apiFetch(`/api/kids/events/${eventId}/check-out`, { method: "POST", authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
} catch (err) {
setWrongCode(errorMessage(err) || "Wrong code");
}
loadBoard();
return;
}
}
setLookup(l);
setOutPicked(Object.fromEntries(l.children.filter(c => !c.checkedOutAt).map(c => [c.id, true])));
setCollectedBy(l.family?.parents[0]?.name || "");
};
const doCheckOut = async () => {
if (!lookup) return;
const ids = Object.entries(outPicked).filter(([, on]) => on).map(([id]) => id);
if (!ids.length) { setMsg({ type: "err", text: "Tick the children being collected." }); return; }
if (!collectedBy.trim()) { setMsg({ type: "err", text: "Enter who is collecting." }); return; }
if (working === "offline" && offline) {
const at = new Date().toISOString();
const next = { ...offline, checkOuts: [...offline.checkOuts, ...ids.map(clientId => ({ clientId, collectedBy, at }))] };
setOffline(next);
saveOffline(eventId, next);
setMsg({ type: "ok", text: `Checked out on this device: ${lookup.children.filter(c => ids.includes(c.id)).map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
return;
}
setBusy(true);
try {
const r = await apiFetch<{ checkedOut: { name: string }[] }>(`/api/kids/events/${eventId}/check-out`, {
method: "POST", authToken: token, body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
});
setMsg({ type: "ok", text: `Collected: ${r.checkedOut.map(c => c.name).join(", ")}.` });
setLookup(null);
setCode("");
loadBoard();
} catch (err) {
fail(err, "Check-out failed");
} finally {
setBusy(false);
}
};
// ─── Offline list ───────────────────────────────────────────────────────────
const getReady = async () => {
setBusy(true);
try {
const snapshot = await apiFetch<OfflineState["snapshot"]>(`/api/kids/events/${eventId}/offline?deviceId=${encodeURIComponent(deviceId())}${sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` : ""}`, { authToken: token });
const prev = loadOffline(eventId);
const sameOccasion = prev?.snapshot.occasion === snapshot.occasion;
const state: OfflineState = {
snapshot,
familyCodes: sameOccasion ? prev!.familyCodes : {},
checkIns: sameOccasion ? prev!.checkIns : [],
checkOuts: sameOccasion ? prev!.checkOuts : [],
};
saveOffline(eventId, state);
setOffline(state);
setMsg({ type: "ok", text: `Offline list ready: ${snapshot.families.length} families, ${snapshot.codes.length} pickup codes for this device.` });
} catch (err) {
fail(err, "Could not download the offline list");
} finally {
setBusy(false);
}
};
const sync = async () => {
if (!offline) return;
setBusy(true);
try {
const r = await apiFetch<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>(`/api/kids/events/${eventId}/sync`, {
method: "POST", authToken: token,
body: { deviceId: deviceId(), occasion: offline.snapshot.occasion, checkIns: offline.checkIns, checkOuts: offline.checkOuts },
});
const next: OfflineState = { ...offline, checkIns: [], checkOuts: [] };
saveOffline(eventId, next);
setOffline(next);
setWorking("online");
setMsg({
type: r.conflicts.length ? "err" : "ok",
text: `Synced: ${r.checkedIn} checked in, ${r.checkedOut} checked out.${r.conflicts.length ? ` Check: ${r.conflicts.map(c => c.message).join(" ")}` : ""}`,
});
loadBoard();
} catch (err) {
setMsg({ type: "err", text: errorMessage(err) || "Sync failed — still offline? Your check-ins are kept on this device." });
} finally {
setBusy(false);
}
};
const queued = (offline?.checkIns.length || 0) + (offline?.checkOuts.length || 0);
const ready = working === "offline" ? !!offline : !!board;
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids check-in</h1>
<p className="text-sm text-gray-500">{board?.sessionLabel ? `${board.sessionLabel} · ` : ""}Check children in and out with pickup codes</p>
</div>
</div>
<Link href="/dashboard/staff/kids/rooms" className="text-sm px-3 py-2 rounded-lg border inline-flex items-center gap-1.5"><Users className="w-4 h-4" /> Room screen</Link>
</div>
<KidsEventPicker token={token} eventId={eventId} sessionId={sessionId} onChange={(id, sid) => { setEventId(id); setSessionId(sid); }} />
{eventId && (
<div className={`mt-3 p-3 rounded-lg border text-sm flex flex-wrap items-center gap-2 ${working === "offline" ? "bg-amber-50 border-amber-300" : "bg-gray-50"}`}>
<CloudOff className="w-4 h-4 text-gray-500" />
{working === "offline" ? (
<>
<span className="font-medium text-amber-900">Working offline</span>
<span className="text-amber-800">{queued} change{queued === 1 ? "" : "s"} waiting to sync · {Math.max(0, (offline?.snapshot.codes.length || 0) - Object.keys(offline?.familyCodes || {}).length)} codes left</span>
<button disabled={busy} onClick={sync} className="ml-auto px-3 py-1.5 rounded-lg bg-amber-600 text-white inline-flex items-center gap-1"><RefreshCw className="w-3.5 h-3.5" /> Back online — sync</button>
</>
) : (
<>
<span className="text-gray-700">{offline ? `Offline list from ${new Date(offline.snapshot.downloadedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "Wi-Fi unreliable? Download the list first."}</span>
{queued > 0 && <button disabled={busy} onClick={sync} className="px-3 py-1.5 rounded-lg bg-amber-600 text-white">Sync {queued} offline change{queued === 1 ? "" : "s"}</button>}
<button disabled={busy} onClick={getReady} className="ml-auto px-3 py-1.5 rounded-lg border bg-white">{offline ? "Refresh offline list" : "Get ready for offline"}</button>
{offline && <button onClick={() => setWorking("offline")} className="px-3 py-1.5 rounded-lg border bg-white">Work offline</button>}
</>
)}
</div>
)}
{boardError && working === "online" && <div className="mt-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{boardError}</div>}
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{ready && (
<>
<div className="flex flex-wrap gap-2 my-4">
<button className={tabCls(tab === "in")} onClick={() => setTab("in")}><LogIn className="w-4 h-4" /> Check in</button>
<button className={tabCls(tab === "out")} onClick={() => setTab("out")}><LogOut className="w-4 h-4" /> Check out</button>
{working === "online" && <button className={tabCls(tab === "rooms")} onClick={() => setTab("rooms")}><Users className="w-4 h-4" /> Who&apos;s in ({board?.checkIns.filter(c => !c.checkedOutAt).length || 0})</button>}
</div>
{(tab === "in" || tab === "out") && (
<div className="mb-4">
<button onClick={() => setScanning(s => !s)} className="text-sm px-3 py-2 rounded-lg border inline-flex items-center gap-1.5 bg-white"><QrCode className="w-4 h-4" /> {scanning ? "Stop scanning" : tab === "in" ? "Scan family QR" : "Scan pickup QR"}</button>
{scanning && <div className="mt-2 max-w-sm"><QRScanner onResult={onScan} /></div>}
</div>
)}
{tab === "in" && (
result ? (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<StickerPanel code={result.code} offline={working === "offline"} kids={result.checkIns.map(c => ({ id: c.id, firstName: c.firstName, room: c.room?.name || null, sticker: c.sticker, already: c.already }))} />
<button onClick={() => { setResult(null); setFamily(null); setQ(""); }} className="mt-4 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Next family</button>
</section>
) : (
<div className="grid lg:grid-cols-2 gap-4">
<section className="border rounded-xl p-4 bg-white shadow-sm">
<form onSubmit={search} className="flex gap-2">
<input className={inputCls} placeholder="Parent or child name, or phone number" value={q} onChange={e => setQ(e.target.value)} aria-label="Find a family" />
<button className="px-3 py-2 rounded-lg border" aria-label="Search"><Search className="w-4 h-4" /></button>
</form>
{results && (
<ul className="mt-3 divide-y">
{results.map(f => (
<li key={f.id}>
<button onClick={() => chooseFamily(f)} className="w-full text-left py-2 hover:bg-gray-50 px-1 rounded">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-gray-500">{f.parents.map(p => p.name).join(", ")} · {f.children.map(c => c.firstName).join(", ") || "no children yet"}</div>
</button>
</li>
))}
{!results.length && <li className="py-2 text-sm text-gray-500">No family found.</li>}
</ul>
)}
{working === "online" && !newFamily && (
<button onClick={() => setNewFamily({ name: "", parentName: "", phone: "", email: "" })} className="mt-3 text-sm inline-flex items-center gap-1 text-brand-700"><UserPlus className="w-4 h-4" /> New family</button>
)}
{newFamily && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="font-medium text-sm">New family</div>
<input className={inputCls} placeholder="Parent's name *" value={newFamily.parentName} onChange={e => setNewFamily({ ...newFamily, parentName: e.target.value })} />
<div className="grid grid-cols-2 gap-2">
<input className={inputCls} placeholder="Phone" value={newFamily.phone} onChange={e => setNewFamily({ ...newFamily, phone: e.target.value })} />
<input className={inputCls} placeholder="Email" value={newFamily.email} onChange={e => setNewFamily({ ...newFamily, email: e.target.value })} />
</div>
<input className={inputCls} placeholder="Family name (e.g. Smith family)" value={newFamily.name} onChange={e => setNewFamily({ ...newFamily, name: e.target.value })} />
<div className="text-xs text-gray-500">First child:</div>
<ChildForm custody={isSupervisor} busy={busy} onSave={createFamily} onCancel={() => setNewFamily(null)} saveLabel="Add family" />
</div>
)}
</section>
{family && (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold">{family.name}</h2>
<div className="text-xs text-gray-500 mb-3">{family.parents.map(p => `${p.name}${p.phone ? ` · ${p.phone}` : ""}`).join(" — ")}</div>
<ul className="space-y-2">
{family.children.map(c => {
const inRoom = inNow.get(c.id);
return (
<li key={c.id} className="border rounded-lg p-2 flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 flex-1 min-w-[10rem]">
<input type="checkbox" disabled={inRoom !== undefined} checked={!!picked[c.id]} onChange={e => setPicked(p => ({ ...p, [c.id]: e.target.checked ? (suggestRoom(rooms, c.age)?.id || "") : "" }))} />
<span>
<span className="font-medium">{c.name}</span>{c.age != null && <span className="text-sm text-gray-500"> · {c.age}</span>}
{c.allergies && <span className="block text-xs text-red-700">Allergies: {c.allergies}</span>}
{c.hasCustody && <span className="block text-xs text-red-900">Custody notes — see room screen</span>}
</span>
</label>
{inRoom !== undefined ? <span className="text-xs px-2 py-1 rounded-full bg-green-50 text-green-700">In — {inRoom}</span> : picked[c.id] ? (
<select aria-label={`Room for ${c.firstName}`} className="border rounded-lg px-2 py-1 text-sm" value={picked[c.id]} onChange={e => setPicked(p => ({ ...p, [c.id]: e.target.value }))}>
{rooms.map(r => <option key={r.id} value={r.id}>{r.name}{r.full ? " (full)" : ""}</option>)}
</select>
) : null}
</li>
);
})}
</ul>
{working === "online" && (addingChild ? (
<div className="mt-3 border-t pt-3"><ChildForm custody={isSupervisor} busy={busy} onSave={addChild} onCancel={() => setAddingChild(false)} saveLabel="Add child" /></div>
) : (
<button onClick={() => setAddingChild(true)} className="mt-2 text-sm text-brand-700">+ Add a child</button>
))}
{working === "online" && family.adults.some(a => a.phone) && (
<label className="block text-sm mt-3"><span className="block text-xs text-gray-500 mb-1">Also send the code to</span>
<select className={inputCls} value={sendTo} onChange={e => setSendTo(e.target.value)}>
<option value="">Nobody else</option>
{family.adults.filter(a => a.phone).map(a => <option key={a.id} value={a.id}>{a.name}{a.relationship ? ` (${a.relationship})` : ""}</option>)}
</select>
</label>
)}
<button disabled={busy || !Object.values(picked).some(Boolean)} onClick={doCheckIn} className="mt-4 w-full px-4 py-3 rounded-lg bg-brand-600 text-white font-semibold disabled:opacity-50">Check in</button>
</section>
)}
</div>
)
)}
{tab === "out" && (
<section className="border rounded-xl p-4 bg-white shadow-sm max-w-xl">
<form onSubmit={e => { e.preventDefault(); lookUpCode(); }} className="flex gap-2">
<input className={`${inputCls} text-2xl font-mono tracking-widest uppercase`} placeholder="Pickup code" value={code} onChange={e => setCode(e.target.value)} maxLength={10} aria-label="Pickup code" />
<button className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Find</button>
</form>
{wrongCode && <div role="alert" className="mt-3 p-3 rounded-lg border-2 border-red-500 bg-red-50 text-red-900 text-sm font-medium">{wrongCode}</div>}
{lookup && (
<div className="mt-4">
{lookup.family && (
<div className="text-sm mb-3">
<div className="font-semibold">{lookup.family.name}</div>
<div className="text-xs text-gray-600">Parents: {lookup.family.parents.map(p => p.name).join(", ") || "—"}</div>
{lookup.family.adults.length > 0 && <div className="text-xs text-gray-600">May also collect: {lookup.family.adults.map(a => `${a.name}${a.relationship ? ` (${a.relationship})` : ""}`).join(", ")}</div>}
</div>
)}
<ul className="space-y-1 mb-3">
{lookup.children.map(c => {
const custody = board?.checkIns.find(b => b.id === c.id)?.custodyNotes;
return (
<li key={c.id} className="text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" disabled={!!c.checkedOutAt} checked={!!outPicked[c.id]} onChange={e => setOutPicked(p => ({ ...p, [c.id]: e.target.checked }))} />
<span className="font-medium">{c.name}</span> <span className="text-gray-500">{c.room?.name}</span>
{c.checkedOutAt && <span className="text-xs text-gray-500">collected by {c.collectedBy || "—"}</span>}
</label>
{custody && <div className="ml-6 mt-1 text-xs text-red-900 bg-red-50 rounded px-2 py-1">Custody: {custody}</div>}
</li>
);
})}
</ul>
<label className="block text-sm"><span className="block text-xs text-gray-500 mb-1">Collected by</span>
<input className={inputCls} list="kids-collectors" value={collectedBy} onChange={e => setCollectedBy(e.target.value)} />
<datalist id="kids-collectors">
{lookup.family?.parents.map(p => <option key={p.userId} value={p.name} />)}
{lookup.family?.adults.map(a => <option key={a.id} value={a.name} />)}
</datalist>
</label>
<button disabled={busy} onClick={doCheckOut} className="mt-3 w-full px-4 py-3 rounded-lg bg-brand-600 text-white font-semibold disabled:opacity-50">Check out</button>
</div>
)}
</section>
)}
{tab === "rooms" && board && working === "online" && (
<RoomBoard board={board} token={token} roomId="" onChanged={() => loadBoard()} onMessage={setMsg} />
)}
</>
)}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Users } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
import { RoomBoard, RoomStatusBadges } from "@/components/kids/RoomBoard";
import type { KidsBoard } from "@/lib/kids";
const ROOM_KEY = "hope_events_kids_room";
/** A kids room's screen: the children in it, allergies and custody notes, "I'm on duty", alerts. */
export default function KidsRoomsPage() {
const { token, user } = useAuth();
const [eventId, setEventId] = useState("");
const [sessionId, setSessionId] = useState("");
const [roomId, setRoomId] = useState("");
const [board, setBoard] = useState<KidsBoard | null>(null);
const [error, setError] = useState<string | null>(null);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
useEffect(() => { try { setRoomId(localStorage.getItem(ROOM_KEY) || ""); } catch { /* ignore */ } }, []);
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
const load = useCallback(async (log = false) => {
if (!token || !eventId) return;
try {
setBoard(await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token }));
setError(null);
} catch (e) {
setBoard(null);
setError(errorMessage(e) || "Could not load the rooms");
}
}, [token, eventId, qs]);
useEffect(() => { load(true); }, [load]);
useVisiblePolling(() => { load(); }, 5000, !!token && !!eventId);
const pickRoom = (id: string) => {
setRoomId(id);
try { localStorage.setItem(ROOM_KEY, id); } catch { /* ignore */ }
};
const room = board?.rooms.find(r => r.id === roomId) || null;
const onDuty = !!room && !!user && room.leaderIds.includes(user.id);
const duty = async (on: boolean) => {
try {
await apiFetch(`/api/kids/events/${eventId}/duty`, { method: "POST", authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
load();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not change duty" });
}
};
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Users className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard/staff/kids" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids rooms</h1>
<p className="text-sm text-gray-500">{board?.sessionLabel ? `${board.sessionLabel} · ` : ""}Who is in each room right now</p>
</div>
</div>
<div className="flex flex-wrap gap-2 items-end">
<KidsEventPicker token={token} eventId={eventId} sessionId={sessionId} onChange={(id, sid) => { setEventId(id); setSessionId(sid); }} />
{board && (
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Room</span>
<select className="border rounded-lg px-3 py-2 text-sm" value={room ? roomId : ""} onChange={e => pickRoom(e.target.value)}>
<option value="">All rooms</option>
{board.rooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
)}
</div>
{room && (
<div className="mt-3 p-3 rounded-lg border bg-white flex flex-wrap items-center gap-3">
<RoomStatusBadges room={room} />
<button onClick={() => duty(!onDuty)} className={`ml-auto px-4 py-2 rounded-lg text-sm font-medium ${onDuty ? "border" : "bg-brand-600 text-white"}`}>
{onDuty ? "I'm going off duty" : "I'm on duty in this room"}
</button>
</div>
)}
{error && <div className="mt-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{board && <div className="mt-4"><RoomBoard board={board} token={token} roomId={room ? roomId : ""} onChanged={() => load()} onMessage={setMsg} /></div>}
</div>
);
}
+4 -1
View File
@@ -6,15 +6,17 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
] as const;
@@ -75,6 +77,7 @@ export default function StaffDashboardPage() {
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
<CampusScopeNote className="mt-2" />
</div>
{!canView && (
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -735,6 +736,7 @@ function EmailAttendeesPageInner() {
channel="email"
/>
<div className="text-[11px] text-gray-500 mb-1">Select users from your database to include. Green = Email/both preference. You can also add external emails below.</div>
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
@@ -8,6 +8,8 @@ import { apiFetch, API_BASE } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { Calendar } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
@@ -830,6 +832,7 @@ interface EventDraft {
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string; allowNamedAttendees: boolean;
campusId: string; // "" = all campuses
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
@@ -845,6 +848,7 @@ const blankDraft = (location = ""): EventDraft => ({
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location, allowNamedAttendees: false,
campusId: "",
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
@@ -873,6 +877,10 @@ interface EventModalProps {
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token } = useAuth();
const { settings } = useSiteSettings();
const campusInfo = useMyCampuses();
// Campus-scoped: events for all campuses (and other campuses') are set up by admins and
// supervisors for every campus — the server refuses changes, so the editor says so up front.
const campusReadOnly = mode === "edit" && campusInfo.scoped && !campusInfo.campuses.some(c => c.id === ev?.campusId);
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false);
@@ -897,10 +905,23 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
campusId: ev.campusId || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
// A campus-scoped person's new event starts at their (first) campus — they can't make
// events for all campuses — with that campus's default location if the field is still default.
useEffect(() => {
if (mode !== "create" || !campusInfo.scoped || !campusInfo.campuses.length) return;
setDraft(d => {
if (d.campusId) return d;
const first = campusInfo.campuses[0];
const isDefault = !d.location.trim() || d.location === (settings.org_address || "");
return { ...d, campusId: first.id, ...(first.defaultLocation && isDefault ? { location: first.defaultLocation } : {}) };
});
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
// ── multi-day schedule + attendance settings ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
@@ -1284,6 +1305,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
// Only sent while campuses are on; the server ignores it otherwise.
...(campusInfo.enabled ? { campusId: draft.campusId || null } : {}),
};
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
@@ -1417,6 +1440,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
This event is closed — it can&apos;t be edited until it&apos;s reopened from the cashup page (admin only).
</div>
)}
{campusReadOnly && (
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
{ev?.campusId ? "This event belongs to another campus" : "This is an event for all campuses"} — only admins and supervisors for every campus can change it. You can still work with its registrations, payments and tickets.
</div>
)}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{changedNotice && (
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm space-y-2">
@@ -1473,6 +1501,29 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
{campusInfo.enabled && (
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-campus">Campus</label>
<select
id="event-campus"
className="w-full border rounded px-3 py-2 text-sm bg-white"
value={draft.campusId}
onChange={e => {
const next = campusInfo.campuses.find(c => c.id === e.target.value);
const prev = campusInfo.campuses.find(c => c.id === draft.campusId);
// Swap in the campus's default location while the field still holds a default.
const isDefault = !draft.location.trim() || draft.location === (settings.org_address || "") || draft.location === (prev?.defaultLocation || "");
upd({ campusId: e.target.value, ...(next?.defaultLocation && isDefault ? { location: next.defaultLocation } : {}) });
}}
>
{campusInfo.scoped ? <option value="" disabled>Choose your campus…</option> : <option value="">All campuses</option>}
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{/* An archived campus stays on events that already have it. */}
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">&quot;All campuses&quot; events show at every campus — for church-wide conferences and camps.</p>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
@@ -1815,8 +1866,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<button type="button" onClick={handleNext}
className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Next →</button>
)}
<button type="button" disabled={saving || isClosed} onClick={handleSave}
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
<button type="button" disabled={saving || isClosed || campusReadOnly} onClick={handleSave}
title={isClosed ? "This event is closed — reopen it first to make changes" : campusReadOnly ? "Only admins and supervisors for every campus can change this event" : undefined}
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : "Save Changes"}
</button>
@@ -1844,6 +1895,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{isPast && !isInactive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
<CampusBadge campus={ev.campus} />
</div>
<div className="text-xs text-gray-500 mt-0.5">
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
@@ -1897,6 +1949,7 @@ export default function ManageEventsPage() {
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
const [search, setSearch] = useState("");
const [campus, setCampus] = useState("");
const isAdmin = user?.role === "admin";
const now = Date.now();
@@ -1905,8 +1958,9 @@ export default function ManageEventsPage() {
if (!showClosed && ev.cashupStatus === "closed") return false;
if (!includePast && ev.endDate && new Date(ev.endDate).getTime() < now) return false;
if (search && !ev.title?.toLowerCase().includes(search.toLowerCase())) return false;
if (!matchesCampus(ev, campus)) return false;
return true;
}), [events, includePast, showInactive, showClosed, search, now]);
}), [events, includePast, showInactive, showClosed, search, campus, now]);
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: Event) => {
@@ -1940,6 +1994,7 @@ export default function ManageEventsPage() {
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
<input className="border rounded px-3 py-1.5 text-sm w-48" placeholder="Search by title…" value={search} onChange={e => setSearch(e.target.value)} />
<CampusFilter value={campus} onChange={setCampus} withChurchWide mine />
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={includePast} onChange={e => setIncludePast(e.target.checked)} /> Include past
</label>
@@ -0,0 +1,167 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, Plus, Trash2 } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
type Settings = {
event: { id: string; title: string; scheduleType: string; kidsCheckIn: boolean; kidsOptionId: string | null; kidsRoomSetId: string | null };
options: { id: string; name: string; price: number }[];
sets: { id: string; name: string; groups: Room[] }[];
};
type SetupEvent = { id: string; title: string; startDate: string; kidsCheckIn: boolean };
const inputCls = "w-full border rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const num = (v: string) => (v.trim() === "" ? null : Number(v));
/** Supervisor → Kids check-in setup: switch it on for an event, choose the kids ticket and the rooms. */
export default function KidsSetupPage() {
const { token } = useAuth();
const [events, setEvents] = useState<SetupEvent[]>([]);
const [eventId, setEventId] = useState("");
const [s, setS] = useState<Settings | null>(null);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<Record<string, { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }>>({});
const [newRoom, setNewRoom] = useState({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" });
useEffect(() => {
if (!token) return;
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
}, [token, setMsg]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
setS(data);
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
}
}, [token, eventId, setMsg]);
useEffect(() => { setS(null); load(); }, [load]);
const run = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
setMsg({ type: "ok", text: ok });
await load();
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
return false;
} finally {
setBusy(false);
}
};
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
const addRoom = () => s?.event.kidsRoomSetId && run(
() => apiFetch(`/api/allocations/sets/${s.event.kidsRoomSetId}/groups`, { method: "POST", authToken: token, body: roomBody(newRoom) }),
"Room added.",
).then(ok => { if (ok) setNewRoom({ name: "", minAge: "", maxAge: "", capacity: "", ratio: "" }); });
const saveRoom = (id: string) => run(() => apiFetch(`/api/allocations/groups/${id}`, { method: "PUT", authToken: token, body: roomBody(draft[id]) }), "Room saved.");
const deleteRoom = (r: Room) => confirm(`Delete ${r.name}? Children checked in to it now will need a new room.`) && run(() => apiFetch(`/api/allocations/groups/${r.id}`, { method: "DELETE", authToken: token }), "Room deleted.");
const roomRow = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }, set: (v: typeof d) => void) => (
<>
<input aria-label="Room name" className={inputCls} value={d.name} onChange={e => set({ ...d, name: e.target.value })} placeholder="Toddlers" />
<input aria-label="Youngest age" className={inputCls} inputMode="numeric" value={d.minAge} onChange={e => set({ ...d, minAge: e.target.value })} placeholder="from" />
<input aria-label="Oldest age" className={inputCls} inputMode="numeric" value={d.maxAge} onChange={e => set({ ...d, maxAge: e.target.value })} placeholder="to" />
<input aria-label="Capacity" className={inputCls} inputMode="numeric" value={d.capacity} onChange={e => set({ ...d, capacity: e.target.value })} placeholder="—" />
<input aria-label="Children per leader" className={inputCls} inputMode="numeric" value={d.ratio} onChange={e => set({ ...d, ratio: e.target.value })} placeholder="—" />
</>
);
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids check-in setup</h1>
<p className="text-sm text-gray-500">Switch kids check-in on for an event and set up its rooms</p>
</div>
</div>
<label className="text-sm block max-w-md">
<span className="block text-xs text-gray-500 mb-1">Event</span>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
<option value="">Choose an event…</option>
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
</select>
</label>
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{s && (
<div className="space-y-4 mt-4">
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
<h2 className="font-semibold">1. Kids ticket</h2>
<p className="text-sm text-gray-600">Each child gets a free place on this ticket the first time they&apos;re checked in, so they appear on the attendance register and reports. Parents don&apos;t buy it.</p>
<div className="flex flex-wrap gap-2">
<select className="border rounded-lg px-3 py-2 text-sm" value={s.event.kidsOptionId || ""} onChange={e => save({ optionId: e.target.value || null })} aria-label="Kids ticket">
<option value="">Choose…</option>
{s.options.map(o => <option key={o.id} value={o.id}>{o.name}{o.price ? ` (R${o.price})` : " (free)"}</option>)}
</select>
{!s.event.kidsOptionId && <button disabled={busy} onClick={() => save({ optionId: "new" }, "Added a free \"Kids check-in\" ticket.")} className="px-3 py-2 rounded-lg border text-sm">Add a free &quot;Kids check-in&quot; ticket</button>}
</div>
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm space-y-3">
<h2 className="font-semibold">2. Rooms</h2>
<div className="flex flex-wrap gap-2">
<select className="border rounded-lg px-3 py-2 text-sm" value={s.event.kidsRoomSetId || ""} onChange={e => save({ roomSetId: e.target.value || null })} aria-label="Rooms set">
<option value="">Choose…</option>
{s.sets.map(x => <option key={x.id} value={x.id}>{x.name} ({x.groups.length} rooms)</option>)}
</select>
{!s.event.kidsRoomSetId && <button disabled={busy} onClick={() => save({ roomSetId: "new" }, "Added \"Kids rooms\" — now add the rooms.")} className="px-3 py-2 rounded-lg border text-sm">Add &quot;Kids rooms&quot;</button>}
</div>
{s.event.kidsRoomSetId && (
<div className="overflow-x-auto">
<div className="grid grid-cols-[2fr_repeat(4,minmax(4rem,1fr))_auto] gap-2 items-center min-w-[36rem] text-xs text-gray-500">
<span>Room</span><span>Age from</span><span>Age to</span><span>Capacity</span><span>Per leader</span><span />
{rooms.map(r => draft[r.id] && (
<React.Fragment key={r.id}>
{roomRow(draft[r.id], v => setDraft(d => ({ ...d, [r.id]: v })))}
<span className="flex gap-1">
<button disabled={busy} onClick={() => saveRoom(r.id)} className="px-2 py-1.5 rounded-lg border text-sm text-gray-800">Save</button>
<button disabled={busy} aria-label={`Delete ${r.name}`} onClick={() => deleteRoom(r)} className="p-1.5 rounded-lg border"><Trash2 className="w-4 h-4" /></button>
</span>
</React.Fragment>
))}
{roomRow(newRoom, setNewRoom)}
<button disabled={busy || !newRoom.name.trim()} onClick={addRoom} className="px-2 py-1.5 rounded-lg bg-brand-600 text-white text-sm inline-flex items-center gap-1 disabled:opacity-50"><Plus className="w-4 h-4" /> Add</button>
</div>
<p className="text-xs text-gray-500 mt-2">Ages are whole years (both included). The desk suggests the room with the narrowest age range that fits and isn&apos;t full. &quot;Per leader&quot; is the ratio: the room screen warns when there are more children than the leaders on duty can look after.</p>
</div>
)}
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[14rem]">
<h2 className="font-semibold">3. Switch on</h2>
<p className="text-sm text-gray-600">{s.event.kidsCheckIn ? "Kids check-in is on — staff use Staff → Kids check-in." : "Staff can check children in once this is on."}</p>
</div>
<button disabled={busy} onClick={() => save({ enabled: !s.event.kidsCheckIn }, s.event.kidsCheckIn ? "Kids check-in switched off." : "Kids check-in switched on.")} className={`px-4 py-2 rounded-lg text-sm font-medium ${s.event.kidsCheckIn ? "border" : "bg-brand-600 text-white"}`}>
{s.event.kidsCheckIn ? "Switch off" : "Switch on"}
</button>
{s.event.kidsCheckIn && <Link href="/dashboard/staff/kids" className="px-4 py-2 rounded-lg border text-sm">Open the desk</Link>}
</section>
</div>
)}
</div>
);
}
+10 -2
View File
@@ -9,12 +9,14 @@ 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,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -26,6 +28,8 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/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 },
@@ -75,6 +79,7 @@ export default function SupervisorDashboardPage() {
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [campus, setCampus] = useState("");
const loadStats = useCallback(async () => {
if (!token) return;
@@ -83,7 +88,8 @@ export default function SupervisorDashboardPage() {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
@@ -95,7 +101,7 @@ export default function SupervisorDashboardPage() {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
}, [token, campus, setScanStats, setPaymentStats, setRecentScans, setOverview]);
useEffect(() => {
if (!token) return;
@@ -114,6 +120,8 @@ export default function SupervisorDashboardPage() {
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
<CampusScopeNote className="mt-2" />
</div>
{!canView && (
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -809,6 +810,7 @@ function WhatsAppAttendeesPageInner() {
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
<CampusAudiencePicker selectedIds={selectedUserIds} onChange={setSelectedUserIds} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
@@ -0,0 +1,213 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Baby, BellRing, Pencil, Plus, QrCode, Trash2, UserCheck } from "lucide-react";
import { BackLink } from "@/components/shared/BackLink";
import { QrImage } from "@/components/shared/QrImage";
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
import { FAMILY_QR_PREFIX, type KidsAdult, type KidsChild, type KidsFamily } from "@/lib/kids";
type ActiveCode = { code: string; qr: string; event: { id: string; title: string }; children: { name: string; room: string | null; checkedInAt: string }[] };
type Mine = { family: KidsFamily | null; qrToken: string | null; codes: ActiveCode[]; pages: { id: string; message: string; createdAt: string }[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
/** My account → Kids: the family's children, authorised adults, family QR and live pickup codes. */
export default function MyKidsPage() {
const { token, user } = useAuth();
const [data, setData] = useState<Mine | null>(null);
const [msg, setMsg] = useDismissingState<Msg>(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [adultForm, setAdultForm] = useState<{ id?: string; name: string; phone: string; relationship: string } | null>(null);
const [familyName, setFamilyName] = useState("");
const load = useCallback(async () => {
if (!token) return;
try {
setData(await apiFetch<Mine>("/api/kids/me", { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load your family" });
}
}, [token, setMsg]);
useEffect(() => { load(); }, [load]);
// Codes and pages change while children are checked in.
useVisiblePolling(() => { if (data?.codes.length) load(); }, 15000, !!token);
const call = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
setMsg({ type: "ok", text: ok });
await load();
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" });
return false;
} finally {
setBusy(false);
}
};
const startFamily = () => call(() => apiFetch("/api/kids/me", { method: "POST", authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
const saveChild = (child: KidsChild | null, input: ChildInput) => call(
() => apiFetch(child ? `/api/kids/me/children/${child.id}` : "/api/kids/me/children", { method: child ? "PUT" : "POST", authToken: token, body: input }),
child ? "Saved." : "Child added.",
).then(ok => { if (ok) setEditing(null); });
const removeChild = (child: KidsChild) => {
if (!confirm(`Remove ${child.firstName}? Their details are deleted.`)) return;
call(() => apiFetch(`/api/kids/me/children/${child.id}`, { method: "DELETE", authToken: token }), "Removed.");
};
const saveAdult = () => adultForm && call(
() => apiFetch(adultForm.id ? `/api/kids/me/adults/${adultForm.id}` : "/api/kids/me/adults", { method: adultForm.id ? "PUT" : "POST", authToken: token, body: adultForm }),
"Saved.",
).then(ok => { if (ok) setAdultForm(null); });
const removeAdult = (a: KidsAdult) => {
if (!confirm(`Remove ${a.name} from your authorised adults?`)) return;
call(() => apiFetch(`/api/kids/me/adults/${a.id}`, { method: "DELETE", authToken: token }), "Removed.");
};
const share = async (c: ActiveCode) => {
const text = `Pickup code for ${c.children.map(k => k.name.split(" ")[0]).join(", ")} at ${c.event.title}: ${c.code}`;
try {
if (navigator.share) await navigator.share({ text });
else { await navigator.clipboard.writeText(text); setMsg({ type: "ok", text: "Copied — paste it into a message to whoever is collecting." }); }
} catch { /* cancelled */ }
};
const family = data?.family;
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0"><Baby className="w-5 h-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard/user" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Kids</h1>
<p className="text-sm text-gray-500">Your children for kids check-in, and their pickup codes</p>
</div>
</div>
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{data?.pages.map(p => (
<div key={p.id} className="mb-4 p-4 rounded-xl border-2 border-amber-400 bg-amber-50 flex gap-3 items-start">
<BellRing className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div><div className="font-semibold text-amber-900">{p.message}</div><div className="text-xs text-amber-700">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
</div>
))}
{data?.codes.map(c => (
<section key={c.event.id + c.code} className="mb-4 border-2 border-brand-500 rounded-xl p-4 bg-white shadow-sm">
<div className="text-xs uppercase tracking-wide text-brand-700 font-semibold">Checked in · {c.event.title}</div>
<div className="flex flex-wrap items-center gap-4 mt-2">
<QrImage value={c.qr} size={160} className="w-32 h-32" alt={`Pickup QR code ${c.code}`} />
<div>
<div className="text-xs text-gray-500">Pickup code</div>
<div className="text-5xl font-black tracking-[0.2em] font-mono" data-testid="pickup-code">{c.code}</div>
<ul className="mt-2 text-sm text-gray-700">{c.children.map(k => <li key={k.name}>{k.name}{k.room ? ` — ${k.room}` : ""}</li>)}</ul>
</div>
</div>
<p className="text-xs text-gray-500 mt-3">Show this code or QR to collect your children. Anyone you give the code to can collect them.</p>
<button onClick={() => share(c)} className="mt-2 text-sm px-3 py-1.5 rounded-lg border">Send the code to someone else</button>
</section>
))}
{data && !family && (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold mb-1">Add your family</h2>
<p className="text-sm text-gray-600 mb-3">Add your children once, and the kids desk can check them in quickly every time — scan your family QR code or give your phone number.</p>
<div className="flex gap-2">
<input className={inputCls} placeholder={`${user?.name?.split(" ").slice(-1)[0] || "Smith"} family`} value={familyName} onChange={e => setFamilyName(e.target.value)} />
<button disabled={busy} onClick={startFamily} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium whitespace-nowrap disabled:opacity-50">Add family</button>
</div>
</section>
)}
{family && (
<>
<section className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex items-center justify-between gap-2 mb-3">
<h2 className="font-semibold">{family.name}</h2>
<span className="text-xs text-gray-500">Parents: {family.parents.map(p => p.name).join(", ")}</span>
</div>
<div className="space-y-3">
{family.children.map(c => (
<div key={c.id} className="border rounded-lg p-3">
{editing === c.id ? (
<ChildForm initial={c} custody busy={busy} onSave={v => saveChild(c, v)} onCancel={() => setEditing(null)} />
) : (
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-medium">{c.name}{c.age != null && <span className="text-sm text-gray-500 font-normal"> · {c.age} yrs</span>}</div>
<div className="text-xs text-gray-600 space-x-2">
{c.allergies && <span className="text-red-700">Allergies: {c.allergies}</span>}
{c.hasMedical && <span>Medical notes ✓</span>}
{c.hasCustody && <span>Custody notes ✓</span>}
{!c.allergies && !c.hasMedical && !c.dateOfBirth && <span className="text-amber-700">Add a date of birth so the right room is suggested</span>}
</div>
</div>
<div className="flex gap-1">
<button aria-label={`Edit ${c.firstName}`} onClick={() => setEditing(c.id)} className="p-1.5 rounded hover:bg-gray-100"><Pencil className="w-4 h-4" /></button>
<button aria-label={`Remove ${c.firstName}`} onClick={() => removeChild(c)} className="p-1.5 rounded hover:bg-gray-100"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
)}
</div>
))}
{editing === "new" ? (
<div className="border rounded-lg p-3"><ChildForm custody busy={busy} onSave={v => saveChild(null, v)} onCancel={() => setEditing(null)} saveLabel="Add child" /></div>
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1 text-sm px-3 py-2 rounded-lg border"><Plus className="w-4 h-4" /> Add a child</button>
)}
</div>
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<h2 className="font-semibold flex items-center gap-2"><UserCheck className="w-4 h-4" /> Authorised adults</h2>
<p className="text-sm text-gray-600 mb-3">People who may collect your children (grandparents, friends). They don&apos;t need an account — the room leader sees this list, and the desk can send them the pickup code.</p>
<ul className="space-y-2 mb-3">
{family.adults.map(a => (
<li key={a.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm">
<span>{a.name}{a.relationship ? ` (${a.relationship})` : ""}{a.phone ? ` · ${a.phone}` : ""}</span>
<span className="flex gap-1">
<button aria-label={`Edit ${a.name}`} onClick={() => setAdultForm({ id: a.id, name: a.name, phone: a.phone || "", relationship: a.relationship || "" })} className="p-1.5 rounded hover:bg-gray-100"><Pencil className="w-4 h-4" /></button>
<button aria-label={`Remove ${a.name}`} onClick={() => removeAdult(a)} className="p-1.5 rounded hover:bg-gray-100"><Trash2 className="w-4 h-4" /></button>
</span>
</li>
))}
{!family.adults.length && <li className="text-sm text-gray-500">Nobody yet.</li>}
</ul>
{adultForm ? (
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} placeholder="Name" value={adultForm.name} onChange={e => setAdultForm({ ...adultForm, name: e.target.value })} />
<input className={inputCls} placeholder="Phone" value={adultForm.phone} onChange={e => setAdultForm({ ...adultForm, phone: e.target.value })} />
<input className={inputCls} placeholder="Relationship (e.g. Gran)" value={adultForm.relationship} onChange={e => setAdultForm({ ...adultForm, relationship: e.target.value })} />
<div className="sm:col-span-3 flex gap-2">
<button disabled={busy} onClick={saveAdult} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium disabled:opacity-50">Save</button>
<button onClick={() => setAdultForm(null)} className="px-4 py-2 rounded-lg border text-sm">Cancel</button>
</div>
</div>
) : (
<button onClick={() => setAdultForm({ name: "", phone: "", relationship: "" })} className="inline-flex items-center gap-1 text-sm px-3 py-2 rounded-lg border"><Plus className="w-4 h-4" /> Add an adult</button>
)}
</section>
{data?.qrToken && (
<section className="border rounded-xl p-4 bg-white shadow-sm flex flex-wrap items-center gap-4">
<QrImage value={FAMILY_QR_PREFIX + data.qrToken} size={160} className="w-32 h-32" alt="Family QR code" />
<div className="text-sm text-gray-700 max-w-sm">
<div className="font-semibold flex items-center gap-2"><QrCode className="w-4 h-4" /> Family QR code</div>
Show this at the kids desk to find your family quickly. It only finds your family — it doesn&apos;t collect children; that needs the pickup code.
</div>
</section>
)}
</>
)}
</div>
);
}
@@ -11,6 +11,7 @@ import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -37,6 +38,8 @@ export default function UserProfilePage() {
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [homeCampusId, setHomeCampusId] = useState("");
const campusInfo = useCampuses();
const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
@@ -48,6 +51,7 @@ export default function UserProfilePage() {
setEmail(user.email || "");
setPhone(user.phoneNumber || "");
setNotifPref(user.notificationPreference || "email");
setHomeCampusId(user.homeCampusId || "");
}
}, [user]);
@@ -65,6 +69,7 @@ export default function UserProfilePage() {
email,
phoneNumber: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
...(campusInfo.enabled ? { homeCampusId: homeCampusId || null } : {}),
},
});
// Backend returns a refreshed token — save it so the session stays valid
@@ -335,6 +340,17 @@ export default function UserProfilePage() {
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{campusInfo.enabled && campusInfo.campuses.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="home-campus">Home campus</label>
<select id="home-campus" className={inputCls} value={homeCampusId} onChange={e => setHomeCampusId(e.target.value)}>
<option value="">No home campus</option>
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<p className="mt-1 text-xs text-gray-500">The events page starts on this campus, and you&apos;ll get messages meant for people at this campus.</p>
</div>
)}
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
+3
View File
@@ -9,6 +9,7 @@ import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { soldOutLines } from "@/lib/waitlist";
import { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -45,6 +46,7 @@ type Event = {
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
waitlistEnabled?: boolean;
allowSponsorships?: boolean;
bundles?: PublicBundle[];
@@ -208,6 +210,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<div>
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
<CampusBadge campus={event.campus} link className="mt-2 text-xs" />
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
<Calendar className="w-4 h-4 shrink-0" />
{scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate)}
+5 -14
View File
@@ -1,9 +1,9 @@
import { Metadata } from "next";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { EventsGrid } from "@/components/events/EventsGrid";
import { apiFetchListForPrerender } from "@/lib/api";
import { Calendar, CalendarX } from "lucide-react";
import { Calendar } from "lucide-react";
export const revalidate = 60;
@@ -27,6 +27,8 @@ type Event = {
picture?: string;
isSoldOut?: boolean;
cashupStatus?: string;
campusId?: string | null;
campus?: { id: string; name: string; slug: string; colour: string | null } | null;
};
export default async function EventsPage() {
@@ -52,18 +54,7 @@ export default async function EventsPage() {
<p className="text-sm text-gray-500">{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}</p>
</div>
</div>
{sorted.length === 0 ? (
<div className="text-center py-16 border rounded-xl bg-gray-50">
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No events available right now — check back soon.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.map((event) => (
<EventCard key={event.id} event={event} />)
)}
</div>
)}
<EventsGrid events={sorted} />
</main>
<Footer />
</div>
+14 -1
View File
@@ -23,6 +23,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// same degrade-gracefully posture as layout.tsx's getServerSettings.
}
// Campus pages, while campuses are on (the list is empty otherwise).
let campuses: { slug: string }[] = [];
try {
campuses = (await apiFetch<{ campuses: { slug: string }[] }>("/api/campuses", { nextOptions: { next: { revalidate } } })).campuses || [];
} catch {
// Same as events above.
}
const campusEntries: MetadataRoute.Sitemap = campuses.map((c) => ({
url: `${base}/campus/${c.slug}`,
changeFrequency: "weekly",
priority: 0.6,
}));
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
url: `${base}/events/${e.id}`,
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
@@ -30,5 +43,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7,
}));
return [...staticEntries, ...eventEntries];
return [...staticEntries, ...campusEntries, ...eventEntries];
}
@@ -0,0 +1,261 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Plus, ArrowUp, ArrowDown, Archive, ArchiveRestore, Trash2, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
import { errorMessage } from "@/lib/utils";
type Person = { id: string; name: string; email: string };
type AdminCampus = Campus & {
archivedAt: string | null;
notifyRecipients: Person[];
_count: { events: number; homeOf: number; scopedStaff: number };
};
type Draft = {
name: string; slug: string; address: string; defaultLocation: string;
contactName: string; contactEmail: string; contactPhone: string; colour: string; logoUrl: string;
notifyRecipients: Person[];
};
const EMPTY: Draft = { name: "", slug: "", address: "", defaultLocation: "", contactName: "", contactEmail: "", contactPhone: "", colour: "", logoUrl: "", notifyRecipients: [] };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const toDraft = (c: AdminCampus): Draft => ({
name: c.name, slug: c.slug, address: c.address || "", defaultLocation: c.defaultLocation || "",
contactName: c.contactName || "", contactEmail: c.contactEmail || "", contactPhone: c.contactPhone || "",
colour: c.colour || "", logoUrl: c.logoUrl || "", notifyRecipients: c.notifyRecipients || [],
});
/** Staff who get the daily summary for this campus's events. */
function RecipientsField({ value, onChange }: { value: Person[]; onChange: (v: Person[]) => void }) {
const { token } = useAuth();
const [search, setSearch] = useState("");
const [results, setResults] = useState<Person[]>([]);
useEffect(() => {
if (!search.trim()) { setResults([]); return; }
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data?: (Person & { role?: string })[] }>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token });
if (!cancelled) setResults((res?.data || []).filter(u => u.role !== "user"));
} catch { if (!cancelled) setResults([]); }
}, 300);
return () => { cancelled = true; clearTimeout(t); };
}, [search, token]);
const shown = results.filter(r => !value.some(v => v.id === r.id));
return (
<div className="space-y-2">
{value.length > 0 && (
<ul className="flex flex-wrap gap-2">
{value.map(p => (
<li key={p.id} className="inline-flex items-center gap-1.5 text-xs bg-gray-100 rounded-full pl-3 pr-1 py-1">
{p.name}
<button type="button" onClick={() => onChange(value.filter(v => v.id !== p.id))} className="rounded-full px-1.5 hover:bg-gray-200" aria-label={`Remove ${p.name}`}>×</button>
</li>
))}
</ul>
)}
<input className={inputCls} placeholder="Search staff by name or email…" value={search} onChange={e => setSearch(e.target.value)} />
{shown.length > 0 && (
<ul className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{shown.map(u => (
<li key={u.id}>
<button type="button" className="w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50" onClick={() => { onChange([...value, u]); setSearch(""); setResults([]); }}>
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
function CampusForm({ initial, saving, onSave, onCancel }: { initial: Draft; saving: boolean; onSave: (d: Draft) => void; onCancel: () => void }) {
const [d, setD] = useState<Draft>(initial);
const set = (k: keyof Draft) => (e: React.ChangeEvent<HTMLInputElement>) => setD(prev => ({ ...prev, [k]: e.target.value }));
return (
<form onSubmit={e => { e.preventDefault(); onSave(d); }} className="border rounded-xl p-4 space-y-3 bg-gray-50">
<div className="grid sm:grid-cols-2 gap-3">
<label className="block text-sm"><span className="text-gray-700 font-medium">Name</span>
<input className={inputCls} value={d.name} onChange={set("name")} required maxLength={100} placeholder="e.g. Henley Campus" /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Web address</span>
<input className={inputCls} value={d.slug} onChange={set("slug")} maxLength={60} placeholder="made from the name" />
<span className="text-xs text-gray-500">The campus page: /campus/{d.slug || "…"}</span></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Address</span>
<input className={inputCls} value={d.address} onChange={set("address")} maxLength={300} /></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Default event location</span>
<input className={inputCls} value={d.defaultLocation} onChange={set("defaultLocation")} maxLength={300} placeholder="Filled in when an event is created for this campus" /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact name</span>
<input className={inputCls} value={d.contactName} onChange={set("contactName")} maxLength={100} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact email</span>
<input type="email" className={inputCls} value={d.contactEmail} onChange={set("contactEmail")} maxLength={200} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Contact phone</span>
<input className={inputCls} value={d.contactPhone} onChange={set("contactPhone")} maxLength={40} /></label>
<label className="block text-sm"><span className="text-gray-700 font-medium">Colour</span>
<span className="flex gap-2">
<input type="color" className="h-9 w-12 border rounded" value={d.colour || "#2563eb"} onChange={set("colour")} aria-label="Pick a colour" />
<input className={inputCls} value={d.colour} onChange={set("colour")} placeholder="optional, e.g. #1d4ed8" maxLength={7} />
</span></label>
<label className="block text-sm sm:col-span-2"><span className="text-gray-700 font-medium">Logo address</span>
<input className={inputCls} value={d.logoUrl} onChange={set("logoUrl")} maxLength={500} placeholder="optional, https://…" /></label>
</div>
<div>
<p className="text-sm text-gray-700 font-medium">Daily summary for this campus</p>
<p className="text-xs text-gray-500 mb-2">These staff also get the daily summary of this campus&apos;s events, on top of each event&apos;s own recipients.</p>
<RecipientsField value={d.notifyRecipients} onChange={v => setD(prev => ({ ...prev, notifyRecipients: v }))} />
</div>
<div className="flex gap-2">
<button disabled={saving || !d.name.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{saving ? "Saving…" : "Save campus"}</button>
<button type="button" onClick={onCancel} className="px-4 py-2 rounded-lg border text-sm hover:bg-white">Cancel</button>
</div>
</form>
);
}
/** Site settings → Campuses: turn campuses on, and add, order, archive and delete them. */
export function CampusesTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [enabled, setEnabled] = useState(false);
const [campuses, setCampuses] = useState<AdminCampus[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ enabled: boolean; campuses: AdminCampus[] }>("/api/campuses/admin", { authToken: token });
setEnabled(res.enabled);
setCampuses(res.campuses);
} catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load campuses." }); }
}, [token, setMsg]);
useEffect(() => { if (active) load(); }, [active, load]);
const run = async (fn: () => Promise<unknown>, ok?: string) => {
setBusy(true);
try {
await fn();
reloadCampuses();
await load();
if (ok) setMsg({ type: "ok", text: ok });
return true;
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Something went wrong." });
return false;
} finally { setBusy(false); }
};
const toggle = (on: boolean) => {
// Limits are ignored while campuses are off, so limited staff would see every campus.
const limited = (campuses || []).reduce((n, c) => n + (c._count.scopedStaff || 0), 0);
if (!on && limited > 0 && !window.confirm(`Some staff are limited to campuses (${limited} limit${limited === 1 ? "" : "s"} set). While campuses are off those limits are ignored and they'll see every campus. Turn campuses off?`)) return;
return run(() => apiFetch("/api/campuses/settings", { method: "PUT", authToken: token, body: { enabled: on } }), on ? "Campuses are on." : "Campuses are off — campus fields are hidden, nothing is deleted.");
};
const save = async (d: Draft, id?: string) => {
const body = { ...d, notifyRecipients: undefined, notifyRecipientIds: d.notifyRecipients.map(p => p.id) };
const ok = await run(() => id
? apiFetch(`/api/campuses/${id}`, { method: "PUT", authToken: token, body })
: apiFetch("/api/campuses", { method: "POST", authToken: token, body }).then((c) => {
const created = c as AdminCampus;
// Recipients are set with an update, so a new campus gets them too.
return d.notifyRecipients.length ? apiFetch(`/api/campuses/${created.id}`, { method: "PUT", authToken: token, body: { notifyRecipientIds: body.notifyRecipientIds } }) : created;
}), id ? "Campus saved." : "Campus added.");
if (ok) setEditing(null);
};
const move = (list: AdminCampus[], i: number, dir: -1 | 1) => {
const ids = list.map(c => c.id);
[ids[i], ids[i + dir]] = [ids[i + dir], ids[i]];
return run(() => apiFetch("/api/campuses/order", { method: "PUT", authToken: token, body: { ids } }));
};
const setArchived = (c: AdminCampus, archived: boolean) => run(() => apiFetch(`/api/campuses/${c.id}`, { method: "PUT", authToken: token, body: { archived } }), archived ? `${c.name} archived.` : `${c.name} restored.`);
const remove = (c: AdminCampus) => {
if (!window.confirm(`Delete ${c.name}? This can't be undone.`)) return;
run(() => apiFetch(`/api/campuses/${c.id}`, { method: "DELETE", authToken: token }), `${c.name} deleted.`);
};
if (!campuses) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const activeList = campuses.filter(c => !c.archivedAt);
const archivedList = campuses.filter(c => c.archivedAt);
return (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-gray-900">Campuses</h2>
<p className="text-sm text-gray-500">For a church with more than one campus: each event belongs to a campus or to all campuses, the events page gets a campus filter, and people can choose their home campus. Churches with one campus can leave this off — nobody sees campus fields then.</p>
</div>
<label className="flex items-start gap-3 border rounded-xl p-4 cursor-pointer">
<input type="checkbox" className="mt-1 h-4 w-4" checked={enabled} disabled={busy} onChange={e => toggle(e.target.checked)} />
<span>
<span className="block font-medium text-gray-900">Use campuses</span>
<span className="block text-sm text-gray-500">Turning this off hides campus fields and filters everywhere. Campuses and the campus on each event are kept, and come back when it&apos;s turned on again.</span>
</span>
</label>
{enabled && (
<>
<div className="space-y-2">
{activeList.length === 0 && editing !== "new" && <p className="text-sm text-gray-500">No campuses yet. Add your first one below.</p>}
{activeList.map((c, i) => editing === c.id ? (
<CampusForm key={c.id} initial={toDraft(c)} saving={busy} onSave={d => save(d, c.id)} onCancel={() => setEditing(null)} />
) : (
<div key={c.id} className="border rounded-xl p-3 flex flex-wrap items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0 border" style={{ background: c.colour || "transparent" }} aria-hidden />
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900">{c.name}</p>
<p className="text-xs text-gray-500">
<Link href={`/campus/${c.slug}`} className="hover:underline">/campus/{c.slug}</Link>
{" · "}{c._count.events} event{c._count.events === 1 ? "" : "s"}{" · "}{c._count.homeOf} {c._count.homeOf === 1 ? "person" : "people"} call it home{c._count.scopedStaff ? <>{" · "}{c._count.scopedStaff} staff limited to it</> : null}
{c.address && <> · <MapPin className="inline w-3 h-3" /> {c.address}</>}
</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => move(activeList, i, -1)} disabled={busy || i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
<button onClick={() => move(activeList, i, 1)} disabled={busy || i === activeList.length - 1} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move down"><ArrowDown className="w-4 h-4" /></button>
<button onClick={() => setEditing(c.id)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Edit ${c.name}`}><Pencil className="w-4 h-4" /></button>
<button onClick={() => setArchived(c, true)} disabled={busy} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Archive ${c.name}`} title="Archive: hidden from filters and new events; its events keep it"><Archive className="w-4 h-4" /></button>
{c._count.events === 0 && (
<button onClick={() => remove(c)} disabled={busy} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Delete ${c.name}`}><Trash2 className="w-4 h-4" /></button>
)}
</div>
</div>
))}
</div>
{editing === "new" ? (
<CampusForm initial={EMPTY} saving={busy} onSave={d => save(d)} onCancel={() => setEditing(null)} />
) : (
<button onClick={() => setEditing("new")} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add a campus
</button>
)}
{archivedList.length > 0 && (
<div className="pt-2">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Archived</h3>
<p className="text-xs text-gray-500 mb-2">Archived campuses aren&apos;t offered for new events or as a home campus. Events that already have one keep it.</p>
<div className="space-y-2">
{archivedList.map(c => (
<div key={c.id} className="border rounded-xl p-3 flex items-center gap-3 bg-gray-50">
<p className="flex-1 text-sm text-gray-700">{c.name} <span className="text-xs text-gray-500">· {c._count.events} event{c._count.events === 1 ? "" : "s"}</span></p>
<button onClick={() => setArchived(c, false)} disabled={busy} className="inline-flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-white"><ArchiveRestore className="w-4 h-4" /> Restore</button>
{c._count.events === 0 && (
<button onClick={() => remove(c)} disabled={busy} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Delete ${c.name}`}><Trash2 className="w-4 h-4" /></button>
)}
</div>
))}
</div>
</div>
)}
</>
)}
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More