Files
hope-events/docs/HANDOVER.md
T
joshuaandClaude Opus 5.5 4109cb4258 Express 5, scanner on API v2, v1 deprecation headers (1.51.3)
- Express 5 (qs query parser and req.body = {} kept as in Express 4).
- Scanner and at-the-door check-in use /api/v2/checkin; look-up gives attendee, bookedBy,
  ticketOptionId.
- Shared middleware errors on /api/v2 rewritten to { error: { code, message, reason } };
  apiFetch treats reason like v1's code.
- Deprecation + Link: rel="successor-version" on v1 endpoints that have a v2 replacement.
- Roadmap: open question on how much of v1 to retire in 2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:46:24 +02:00

46 KiB
Raw Blame History

Handover — where things stand and how to carry on

Last updated: 2026-10-29, after 1.51.3 (and Hub 1.5.0). The plan itself is in ROADMAP-v2.md (decisions table at the end); the previous round of features is in FEATURES-ROUND-1.md.

The product is called Cross Events (renamed from Hope Events in 1.41.3); the Hub is the Cross Events Hub. Technical names keep hope-events on purpose — the repo, browser storage keys (hope_events_*), the settings encryption salt, applied migrations, PM2/nginx names, the install folder and the database name — because renaming them would sign everyone out, make encrypted settings unreadable or break existing installs.

Done so far (road to 2.0)

Version What
1.51.3 Express 5 (qs query parser and req.body = {} kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on /api/v2 (legacyErrorsToV2, reason); v1 Deprecation + successor-version headers (src/v2/deprecation.ts)
1.51.2 v2 accounts and check-in: signup, login (+ code step), PATCH /me; check-in look-up and scan with outcomes. src/services/auth.ts (from userController) and src/services/checkin.ts (from ticketController); login/signup/profile pages on v2
1.51.1 v2 registrations and payments: register, get, cancel, checkout; create/cancel moved to src/services/registrations.ts, online checkout to src/services/payments.ts (v1 uses them too); typed frontend calls (apiV2, npm run api:types); register page, Pay now and My Events cancel on v2
1.51.0 TypeScript backend + /api/v2 foundations (§14): tsconfig (allowJs), build to dist/, launcher src/index.js → src/app.js; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at /docs/v2; first endpoints events, event, me, my registrations. Tests tests/v2.test.ts, e2e e2e/api-v2.spec.ts
1.50.2 Searchable event picker (components/shared/EventPicker.tsx) on staff/supervisor pages and the kiosk; searchable reports events list; How webhooks work guide on Site settings → Webhooks (examples from EVENT_TYPES[].example). e2e e2e/event-picker.spec.ts
1.50.1 Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (unpaid, userId, from, donations filters). components/shared/Pager.tsx; e2e e2e/long-lists.spec.ts
1.50.0 Accessibility pass (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, ModalOverlay popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in e2e/accessibility.spec.ts
1.49.2 Saved filters (§9 part 3) on registrations, users, events and reports (/api/saved-filters, components/shared/SavedFilters.tsx). Migration 20261028180000_saved_filters
1.49.1 Bulk actions on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (POST /api/registrations/bulk, utils/bulkRegistrations.js). Migration 20261028170000_registrations_bulk
1.49.0 Dashboard and command palette (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (GET /api/dashboard/today), Ctrl+K palette (GET /api/dashboard/search, pages in lib/commandPages.ts), deep links on events/registrations/users
1.48.2 Four demo seeds (`npm run demo:small
1.48.1 Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login
1.48.0 Outbound webhooks (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration 20261028160000_webhooks
1.47.4 Fix: an update request for a version that has since been overtaken shows as such, with Ask for … instead (and the banner again). Hub 1.5.0 moves the open request on (changed); with an older Hub requestUpdate withdraws and asks again
1.47.3 Season passes (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration 20261028150000_season_pass
1.47.2 Series: year on year report (§6 part 3): GET /api/series/:id/compare (compareSeries in utils/series.js), report seriesComparison in ReportsV2, "Compare" on the Event series page
1.47.1 Event series (§6 part 2): Event series page, Series box on events, public /series/<slug>, "Next in this series", follow (News & announcements email from the series job when a new event goes live). Migration 20261028140000_event_series
1.47.0 Copy an event (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by POST /api/events/:id/copy-from. No registrations, coupons, attachments, kids check-in or volunteer roles
1.46.0 Volunteer rosters (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly
1.46.1 Removed the Gitea CI workflow (no runner): test, lint and deploy by hand
1.46.2 Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page)
1.45.1 Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves
1.45.0 Kids check-in (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children
1.44.0 Campus-scoped staff (§12 part 2): Admin → Users "Limit to campuses"; utils/campusScope.js on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test tests/campusScopeRoutes.test.js
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)
1.41.2 Background jobs page: last-run times were 2 h ahead (Prisma's pg adapter drops the offset of timestamptz in raw queries — read pg-boss times AT TIME ZONE 'UTC'); APP_TIMEZONE documented
1.41.1 Licence fixes — no licence (never connected, or the Hub answers INSTALL_NOT_FOUND, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; LICENCE_CHECK_DISABLED for e2e
1.41.0 Cross Events Hub connection — licence step in /setup and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), /health, "update available", scripts/install.sh. The Hub itself is joshua/events-hub 1.0.0
1.40.0 POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly retention job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, {retention} in the notice
1.39.0 POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page
1.38.0 POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising
1.32.0 Early-bird ribbon on event cards
1.33.0 Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page)
1.34.0 Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, scripts/deploy.sh
1.35.0 Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity)
1.37.4 Deploy script: no tag = latest main; per-install ecosystem.config.js out of git (ecosystem.config.example.js)
1.37.3 Fix: the production build failed on unescaped quotes in a help text
1.37.2 Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection
1.37.1 "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap
1.37.0 PayFast (signed form, ITN checks), ProviderCheckout with amount checks, the "Pay online" page (/pay/<id>) where payers choose a provider
1.36.0 Payment provider interface (backend/src/payments) with Yoco behind it, ProviderTransaction, Payment.provider, Site settings → Payments (on/off and order per provider), R15 minimum
1.35.1 Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts)

Next up

Hub 1.1.0 is deployed with update requests rolled out, and scripts/install.sh has been tried on a test server (both done 2026-10-28).

  1. Decide how far v1 goes before 2.0 (roadmap "Still open"): v2 covers what members, guests and the scanner use; the staff/supervisor/admin screens still call ~350 v1 routes. Then 2.0.0 — AI helpers (§15), AI endpoints on v2 only. Languages (Afrikaans, §10) are removed from scope (2026-10-28).
  2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the real cost plus a markup % (a default and a per-licence override) and counts the cap in charged rand. AI endpoints are /api/v2 only.

Working on this repo

  • Release routine: branch feat/<name> → commit → bump with npm version X.Y.Z --no-git-tag-version in ., backend and frontend → add the ## [X.Y.Z] - date heading under [Unreleased] in CHANGELOG.md → write the church-facing ## X.Y.Z section in WHATS-NEW.md (plain language; — urgent in the heading for a security or serious fix) → git merge --no-ff into main → git tag vX.Y.Z → git push origin main <branch> vX.Y.Z. One minor version per feature.
  • Before tagging: run npx eslint src in frontend over the whole app (the deploy runs next build, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: NEXT_DIST_DIR=.next-buildcheck npx next build (then delete it and git checkout frontend/tsconfig.json).
  • Changelog: add entries to [Unreleased] as you go, in plain language. CHANGELOG.md is the full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every 15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
  • Docs to keep in step: CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md, backend/API_DOCUMENTATION.md, the /docs route list in backend/src/app.js (v2 documents itself), and in-app help in frontend/src/content/help/ (register new pages in registry.ts).
  • Prisma migrations: npx prisma migrate dev --create-only --name x against a local DB. Prisma 7 keeps adding an unrelated _EventNotifyRecipients primary-key/index change — delete those two blocks from the generated SQL. It also rewrites the comment in migration_lock.toml; git checkout that file back. New AdminAuditAction values need a migration.
  • Server Node version: the deployment notes install Node 20, so dependencies must support Node 20 (that's why pg-boss is pinned to 10.x).
  • Deploying: bash scripts/deploy.sh [tag] on the server (no tag = latest main). Each install keeps its own ecosystem.config.js (PM2 app names, port), which is not in git. The script runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was tested by running it against a throwaway clone with npm/pm2 stubbed: across the tracked → untracked change for the PM2 file, deploying main, failed builds and a bad tag.
  • Pushing: pushing from some shells fails with "Failed to authenticate user" (a stale saved credential); pushing from your own terminal works.

Tests

  • Backend unit tests: cd backend && npm test (Jest).
  • PayFast e2e (e2e/payfast.spec.ts) pays into a real PayFast sandbox account and is skipped unless E2E_PAYFAST_MERCHANT_ID, E2E_PAYFAST_MERCHANT_KEY and E2E_PAYFAST_PASSPHRASE are set (from the Sandbox dashboard). PayFast's documented public test merchant (10000100) no longer accepts payments. ITNs can't reach localhost; they're covered by unit tests (backend/tests/payments.test.js) — to try one for real, use a tunnel, a public BACKEND_URL and PAYFAST_SKIP_IP_CHECK=true.
  • End-to-end: npm run test:e2e from the repo root (first time: npm install and npx playwright install chromium). They start their own backend (5100) and frontend (3100) against <your DATABASE_URL database>_e2e, which is dropped and re-seeded every run (e2e/support/prepare-db.js). The DB user must be able to create databases. ~5 minutes in dev mode. Locally, Playwright reuses servers already running on 5100/3100. If the run times out waiting for the frontend (the home page returns 500 until the e2e backend is up), start them yourself — node e2e/support/prepare-db.js, then the backend from backend/ with PORT=5100, the e2e DATABASE_URL, JOBS_ENABLED=false, TWO_FACTOR_REQUIRED_ROLES=false, and npx next dev -p 3100 from frontend/ with NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100 — then run npx playwright test. Don't edit frontend files while it runs.
  • Careful with ad-hoc scripts against a dev/test database that has SMTP set up: registrations and check-ins send real email/WhatsApp. Use made-up @test.local people only.
  • No CI: the Gitea workflow was removed (no machine to run a Docker runner). Run the backend tests, frontend eslint and e2e tests yourself before tagging, and deploy by hand with scripts/deploy.sh <tag> on the server.

Where the multi-day code lives

  • backend/src/utils/sessions.js — all session rules (dates, check-in window, late pricing, pick sessions, capacity, grouping); unit tests in backend/tests/sessions.test.js.
  • backend/src/utils/certificates.js, sessionReminders.js, controllers/attendanceController.js (register + certificates API).
  • backend/src/jobs/index.js — the background job registry.
  • Frontend: src/lib/sessions.ts, src/components/events/ScheduleEditor.tsx, SessionPicker.tsx, SessionSchedule.tsx, src/app/dashboard/supervisor/attendance/.

Where the payment code lives

  • backend/src/payments/index.js — the provider registry and the PaymentProvider interface (documented at the top), provider on/off and order (AppSetting payment_providers), the R15 minimum, and createCheckout. Tests: backend/tests/payments.test.js.
  • backend/src/payments/providers/yoco.js — Yoco: checkouts, webhook signature check, webhook parsing. Keys/webhook admin stays in utils/yoco.js + controllers/yocoController.js.
  • backend/src/payments/webhook.js — shared webhook processing (Payment with provider, donation split, registration status, tickets, emails, ProviderTransaction).
  • backend/src/payments/checkouts.js — registration checkouts (balance, part, + donation).
  • backend/src/payments/status.js — status checks for notifications that never arrived (payment-status job, Payments → Reconcile → "Waiting for payment"). PayFast looks payments up in its transaction history (findPayments); Yoco's Checkout API documents no lookup, so Yoco checkouts are "can't check". Tests: backend/tests/paymentStatus.test.js.
  • Adding a provider: write providers/<id>.js with the same interface, add it to PROVIDERS in index.js, mount handleProviderWebhook('<id>') at /api/webhooks/<id> (routes/webhookRoutes.js), and give it a settings card in frontend/src/components/admin/PaymentsTab.tsx. New providers start switched off.
  • Payment.method stays the wallet type (card, apple_pay…) because cashups bucket on it; the provider is Payment.provider.

Where the privacy (POPIA) code lives

  • backend/src/utils/privacy.js — news opt-in (setNewsPreferences, logged in CommunicationConsent), unsubscribe tokens (HMAC with JWT_SECRET, never expire), filterRecipients (every bulk send goes through it: kind 'update' passes, 'news' only to opted-in accounts and not to MessagingOptOut addresses), email/WhatsApp footers, STOP handling, data requests, deletionBlockers, anonymiseUser, exportUserData + PDF. Tests: backend/tests/privacy.test.js; e2e: e2e/privacy.spec.ts.
  • controllers/privacyController.js + routes/privacyRoutes.js (/api/privacy).
  • Bulk sends: broadcastController, whatsappBroadcastController, and emailEventAttendees / whatsappEventAttendees in eventController take kind; scheduled payloads carry it.
  • WhatsApp STOP: handleIncomingMessage in whatsappController.js (WAWP message event on /api/whatsapp/webhook?secret=…; secret in AppSetting WAWP_WEBHOOK_SECRET).
  • Frontend: app/dashboard/user/privacy, app/unsubscribe, app/dashboard/admin/data-requests, components/shared/NewsPrompt.tsx (dashboard layout), MessageKindPicker.tsx.
  • Anything new that stores personal data must be added to anonymiseUser and exportUserData.
  • Privacy notice: utils/privacyNotice.js (built-in sections, placeholders, versions, draft in AppSetting privacy_notice_draft, noticePromptPending); public page app/legal/privacy (renders whatever /api/privacy/notice returns), admin app/dashboard/admin/privacy + components/admin/privacy/*, prompt components/shared/NoticePrompt.tsx (via PrivacyPrompts.tsx: notice first, then the news question). Changing the built-in text changes the live notice only for installs that haven't published a version yet — so every change to DEFAULT_SECTIONS must also add an entry to BUILT_IN_UPDATES (the release, a summary, the section ids changed). Sites with their own version then see it in the editor until they press "Done — hide this" (AppSetting privacy_notice_builtin_reviewed). A test checks the ids exist.
  • Accepting the notice: sign-up (source: 'signup'), account activation ('activation', the form's terms box) and the after-login prompt ('prompt'). noticePromptPending is 'first' for anyone who never accepted (noticeVersionAccepted null) and 'updated' when a significant version came out later. The e2e seed sets noticeVersionAccepted: 0 so the prompt doesn't cover every test.
  • The cookie button on the privacy page shows above any section whose id contains "cookie".
  • Access log: utils/accessLog.js (logDataAccess(req, { action, what, eventId, records })). Any new bulk export or list of personal data should call it and get a label in ACCESS_ACTIONS. Operators: Operator table, seeded in the 1.39.0 migration.
  • Retention: utils/retention.js (policies in AppSetting retention_policies, runRetention, preview, noticeLines for {retention}), job retention (03:30). Anything new that stores personal data should get a retention rule (a new entry in POLICIES + runner). Inactive = latest of User.createdAt, lastLoginAt (set on login), registrations/event end, attendee rows, payments. Readiness: utils/readiness.js. Breaches: SecurityBreach. Admin panels: components/admin/privacy/{Readiness,Retention,Breaches}Panel.tsx.
  • e2e: privacy-retention.spec.ts runs the retention job on seeded six-year-old accounts (prepare-db.js → retention); privacy-notice.spec.ts publishes a version that needs accepting, then accepts it for all seeded users in afterAll so later spec files don't meet the prompt.

Where the Hub client lives (1.41.0, update requests 1.42.0)

  • Protocol: docs/hub-protocol.md — the same file is in the Hub repo (joshua/events-hub, developed at ../events-hub); change both together.
  • backend/src/hub/: publicKey.js (the production Hub key, built in since 1.42.1 — if the Hub's signing key is ever regenerated, update it here in a release; HUB_PUBLIC_KEY env overrides it for a local Hub), licence.js (verify tokens, work out active / grace / suspended / revoked / unlicensed, cached 30 s), client.js (connect, heartbeat, update request — the last two share signedPost; AppSetting hub_* keys, which the generic settings endpoints never touch), report.js (health, totals, income bands), readOnly.js (the routes blocked in read-only mode — add any new "create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
  • Job hub-heartbeat (every 15 min), routes /api/hub/* (status, connect, heartbeat, update-request), /health.
  • Frontend: components/admin/LicenceTab.tsx (Site settings → Licence, with the Request update button; its ConnectForm is also on the last /setup screen), components/shared/LicenceBanner.tsx (site admins only).
  • Updates: sites never update themselves and never show the deploy command. "Request update" tells the Hub (an update_requested alert + one email to Joshua); the Hub resolves it when the site's heartbeat reports a newer version. Joshua then runs scripts/deploy.sh on the server.
  • Local testing: run the Hub (../events-hub, ports 5200/3200), create a licence there, and start Cross Events with HUB_URL=http://localhost:5200 and HUB_PUBLIC_KEY = the key on Hub → Settings.
  • scripts/install.sh — fresh Ubuntu server to a running site (tried on a test server 2026-10-28).

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 AllocationGroups of Event.kidsRoomSetId (with minAge, maxAge, ratio). Event ids on Event (kidsOptionId, kidsRoomSetId) are plain strings, checked in code.
  • Kids check-in isn't blocked in licence read-only mode, on purpose: it's like scanning tickets, and children's safety at pickup comes first.
  • Frontend: lib/kids.ts (types, room suggestion, offline copy in hope_events_kids_offline_<eventId>), components/kids/* (KidsEventPicker, ChildForm, RoomBoard, StickerPanel), pages app/dashboard/staff/kids (desk), staff/kids/rooms, supervisor/kids (setup), user/kids.
  • e2e: kids.spec.ts on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk checks in, parent sees the code, wrong code then the right one, room alert).

Where volunteer rosters live (1.46.0)

  • backend/src/utils/volunteers.js — everything: shifts (shiftOf: the role's own times, else its session's, else the event's), overlap and check-in-window rules, roles, sign-up / leave / swaps, the free volunteer place (givePlace / releasePlace, VolunteerSlot.registrationId), QR and manual check-in (kids-room roles start a KidsRoomDuty with fromRoster), rosteredForRooms (used by the kids board), messages, the volunteers reminder job, hours served, and the privacy parts (exportVolunteering, forgetVolunteer). Pure parts tested in tests/volunteers.test.js.
  • reservedOptionIds / publicOptions there: the kids and volunteer tickets are left out of public event pages and refused on public registration (eventController, registrationController). A new staff-only ticket type should be added to reservedOptionIds.
  • controllers/volunteerController.js + routes/volunteerRoutes.js (/api/volunteers); campus resolvers servingRole and volunteerSlot; sign-ups, new roles and assigning are in readOnly.js.
  • Swaps: no approval — accepting moves the slot at once (claimed with an updateMany on status: 'open' so two people can't both take it); tellOrganisers emails getEventNotifyEmails(event) (notify recipients, else the creator), not the registrations inbox.
  • The check-in QR holds SERVE-<Event.volunteerCheckInToken>; the page encodes it as a URL (/serve/check-in?code=…) so phone cameras open it directly.
  • Frontend: lib/volunteers.ts, components/volunteers/{RosterSheet,CheckInQr}.tsx, pages dashboard/supervisor/volunteers (roles, QR, ticket, hours), dashboard/staff/volunteers (the day), dashboard/user/serving (?event=<id> opens one event — the shareable sign-up link), serve/check-in.
  • e2e: volunteers.spec.ts on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly, sign-up, cover request, staff take over, QR check-in, roster).

Where copying an event lives (1.47.0)

  • The copy is made in the browser: EventModal in app/dashboard/supervisor/events/page.tsx takes source (create mode, cloning), fills its state from it (cloneOptions leaves out the kids and volunteer tickets and all ids; cancelled sessions dropped; sessions keyed by the source's ids so late joining still points right; sections loaded and mapped onto the copied options; feedback questions without the sent/answered state), and moveAllDates shifts every date for "New start". It saves through the normal create path, so a new field on the event form is copied automatically if it's initialised from base.
  • What the form doesn't hold is copied by the server afterwards: utils/eventClone.js (copyEventSetup: allocation sets/groups except kidsRoomSetId, costs for admins only since costs are admin-only) via POST /api/events/:id/copy-from. Tests: tests/eventClone.test.js, e2e "copy an event" in supervisor.spec.ts.

Where event series live (1.47.1)

  • backend/src/utils/series.js — input cleaning, seriesForEvent, nextInSeries (next public event starting later), splitEvents, follow/unfollow, the series job (runAnnounceJob: public, not started, seriesAnnouncedAt null → claimed, then followers filtered with filterRecipients as news), exportFollows. Tests: tests/series.test.js; e2e series.spec.ts.
  • controllers/seriesController.js + routes/seriesRoutes.js (/api/series, campus-independent: the list filters each series' events by scope). Event create/update take seriesId; changing it clears seriesAnnouncedAt so the new series' followers hear.
  • SeriesFollow is personal data: in anonymiseUser and exportUserData. No retention rule of its own — follows go when the account is anonymised.
  • Frontend: app/dashboard/supervisor/series, app/series/[slug], components/events/SeriesLinks.tsx, FollowSeriesButton.tsx (asks the API itself: the page is cached for everyone), the Series box in the event editor. Series pages aren't in the sitemap yet (no public list endpoint).

Where season passes live (1.47.3)

  • backend/src/utils/seasonPass.js — splitCents / itemShares (the price over the events by normal price, whole cents), notOnSale, passView, cleanPassInput, buyPass (advisory lock per pass, stock checked per item, a registration per event with a tranche at the share), startPassCheckout (checkout metadata { type: 'season_pass', purchaseId }), recordPassPayment (called from payments/webhook.js recordSucceededPayment; allocate pays each registration what it owes; first Payment carries externalId), notOnSeasonPass (route guard on options edit and coupons), exportPurchases. Tests: tests/seasonPass.test.js; e2e in series.spec.ts.
  • refreshPricingForRegistration skips pass registrations; payments/status.js treats a pass checkout as paid once its purchase is.
  • Frontend: components/series/SeasonPassesPanel.tsx (Event series page), components/series/SeasonPassOffers.tsx (series page: buy, "Your passes", Pay now).
  • Not built yet: buying a pass at the door or by staff, cancelling a whole purchase in one go (staff cancel and refund each event's registration), one combined payment email (each event's payment sends its own).

Where outbound webhooks live (1.48.0)

  • Noticing: backend/src/config/db.js wraps the Prisma client with $extends query hooks (registration create/update to paid or cancelled, payment create, ticketUsage create, user create, event create/update to public). New code gets webhooks for free as long as it writes through prisma (updateMany isn't hooked). Hooks only call notice; never put slow work there.
  • backend/src/utils/webhooks.js — event types, the in-memory queue (notice → flush after 1.5 s, which re-reads the record and skips it if it changed back), payload builders (kids/volunteer tickets left out), send (claim, SSRF check, sign, 10 s timeout, retry schedule), runJob (webhooks job: due retries, stuck sends, 30-day prune), resend, sendTest. Queued events are lost if the process stops in that 1.5 s (no outbox table yet).
  • controllers/outboundWebhookController.js + routes/outboundWebhookRoutes.js (/api/outbound-webhooks, admin). Operator register entry per endpoint (operatorId).
  • Adding an event type: add it to EVENT_TYPES, a case in buildPayload, a hook in db.js if needed, and a line in dataShared (operator register text).
  • Frontend: components/admin/WebhooksTab.tsx. Tests: tests/webhooks.test.js, e2e webhooks.spec.ts (runs a receiver on localhost:5199; e2e backend has WEBHOOKS_ALLOW_PRIVATE).

Demo data (1.48.2)

  • backend/scripts/set-demo.js builds everything in memory and writes it with createMany in one transaction (so no webhooks fire). PROFILES says which of the six upcoming events, which past events (by title) and which extras (series, courses, conference, kids, volunteers) each profile gets, and whether campuses are on. A new feature that's worth demoing gets a builder and an extra; add its tables to INSERT_ORDER (parents first).
  • Keep jobs quiet: set anything a job would act on as already done (seriesAnnouncedAt, remindedAt, certificates issued). Demo people use @example.com and no phone numbers.
  • clearData truncates every table except settings and users, but deletes (not truncates) tables that User points at — TRUNCATE CASCADE would empty User with them.
  • Tried by seeding all four into a throwaway database and calling the API (events, series page and report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus scoping).

Where the dashboard and palette live (1.49.0)

  • backend/src/utils/dashboard.js — todaysEvents, moneyIn, pendingItems (each item counted separately, failures logged and skipped; role-only items for admins), search (+ amountOf). Everything goes through eventWhere(scopeFor(req)). Routes: routes/dashboardRoutes.js. A new "something needs doing" count goes in pendingItems (with its link and the order).
  • Frontend: components/dashboard/TodayPanel.tsx, components/shared/CommandPalette.tsx (mounted in app/dashboard/layout.tsx; CommandPaletteButton on the dashboards), lib/commandPages.ts — a new page or setting tab should be added there with its roles.
  • Deep links read window.location.search once on load (no Suspense needed).
  • Tests: tests/dashboard.test.js, e2e dashboard.spec.ts.

Where bulk actions live (1.49.1)

  • backend/src/utils/bulkRegistrations.js — runBulk checks the input (≤ 500, licence read-only for payments and moves) and runs one action per registration, collecting a result for each. Most actions call the existing Express handlers in-process (call(handler, req), with the original request's user, headers and socket so audits keep the IP): createPayment, cancelRegistration, allocationController.assign, emailEventAttendees / whatsappEventAttendees (new filter.registrationIds). Change a rule in those handlers and bulk follows.
  • Moving tickets is its own code (moveOption): the edit handler refuses to remove issued tickets, so the lines are re-pointed in place (tickets and QR codes kept), repriced with resolveOptionPrice, attendees moved, then updateRegistrationStatus.
  • Frontend: components/admin/RegistrationBulkBar.tsx on app/dashboard/admin/registrations. Export goes through /api/reports/excel (access-logged). Tests: tests/bulkRegistrations.test.js, e2e bulk.spec.ts.

Saved filters (1.49.2)

  • backend/src/utils/savedFilters.js (PAGES is the list of pages with saved filters; values are cleaned to simple types) + routes/savedFilterRoutes.js. Removed in anonymiseUser.
  • components/shared/SavedFilters.tsx takes the page's current filter values and an onApply; skipDefault when the URL already says what to show. Adding it to another list page: add the page to PAGES (and the component's page type), then pass the values in and apply them out.
  • e2e saved-filters.spec.ts (deletes what it saves).

Accessibility (1.50.0)

  • Popups: use components/shared/ModalOverlay.tsx (labelledBy = the heading's id, onClose, closeOnBackdrop) instead of a bare fixed inset-0 div — it gives the dialog role, focus trap, Escape and focus return. Radix ui/dialog is fine too.
  • Fields: tie every visible <label> to its control (htmlFor/id, or wrap the control); in lists use an id with the row key, or aria-label. Icon-only buttons need aria-label.
  • Text colour: text-gray-500 is the lightest grey for text on white (gray-400 fails 4.5:1). Links inside a <p>/<label> are underlined by globals.css.
  • Tests: e2e/accessibility.spec.ts — add new public/member pages to it. It checks with axe's WCAG 2.0–2.2 A/AA tags; the app has no dark mode, so only the light theme is checked.
  • Not covered yet: the staff, supervisor and admin screens (scanner, at-the-door, settings …) weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made popups. Do them when those pages are next worked on.

Long lists (1.50.1)

  • A list that grows for ever must page on the server (page/limit, total, pages) or load older items with a before cursor, and offer a search. components/shared/Pager.tsx renders "21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events, payments, audit log, consent/access logs, data requests, My payments.
  • Audit-log actions: a new AdminAuditAction needs a label in ACTION_LABELS (app/dashboard/admin/audit-log/page.tsx) — the filter is built from that list.
  • Still loading everything (fine for now, watch as installs grow): user pickers that call fetchAllUsers (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search picker would replace them; the coupons list; GET /api/registrations without page (other callers still use the plain array).

TypeScript and API v2 (1.51.0)

  • Starting the backend: PM2 runs src/index.js, a launcher. With plain Node it runs dist/app.js when dist/build.json has this version; otherwise tsx compiles on the fly (with a warning). tsx and typescript are runtime dependencies for that fallback. npm run dev and e2e use tsx, so they never pick up a stale dist/. The server is src/app.js.
  • Paths: dist/ mirrors src/, so path.join(__dirname, '..', '..', 'public') works from both. Non-code files in src/ aren't copied (the favicon is read from src/).
  • New code in TypeScript. Convert a JS module when a release touches it; until then import it through src/v2/legacy.ts with a typed signature. TypeScript infers loose types from JS (the Prisma client especially), so v2 code takes prisma from there, typed as PrismaClient.
  • A v2 endpoint is defineRoute({ method, path, tag, summary, access, params?, query?, body?, response, handler }) in src/v2/resources/<area>.ts, imported in src/v2/index.ts. Lists use pageQuery, paged() and pageOf(); single items single(). Dates go out as ISO strings (iso()), so response schemas use isoDate. Throw ApiError(code, message) or notFound(). The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a log line (tests catch it). Staff/supervisor routes must declare campusScope (start-up fails otherwise).
  • Services (src/services/*.ts, 1.51.1): business logic both API versions call. Throw ServiceError(status, message) (fail()); v1 controllers keep their old answers (mostly 400 for any error), v2 maps the status to its codes. Helper errors without a status count as 400, like v1. JS helpers are required with a typed signature at the top of each service.
  • Outcomes, not errors (1.51.2): where an endpoint has several normal answers (scanning: already used, consent missing…), v2 returns 200 with an outcome and keeps errors for real failures. The service returns a union (ScanResult); v1ScanResponse() maps it to v1's statuses.
  • v1 statuses: ServiceError(status, message, code, { v1Status }) when v2 means something different (wrong password: 401 in v2, 400 in v1); v1 controllers use v1StatusOf(error, fallback).
  • Frontend v2 calls: apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken }) (frontend/src/lib/apiV2.ts). After adding or changing a v2 route run npm run api:types in the repo root and commit frontend/src/lib/api-v2/ (tests/v2.test.ts fails while it's stale).
  • Shared middleware on v2 (1.51.3): legacyErrorsToV2 (mounted on /api/v2 before everything) rewrites any { message, code } error from middleware shared with v1 into the v2 shape, with the old code as reason. New middleware can keep writing { message, code }.
  • Deprecating v1: when a v1 endpoint gets a v2 replacement, add it to SUCCESSORS in src/v2/deprecation.ts.
  • Express 5: route paths use path-to-regexp v8 (no *, ? or regex in paths — use {/:x} for optional parts, /*name for wildcards); rejected promises in handlers reach the error handler.

Event picker and webhook guide (1.50.2)

  • Choosing an event: use EventPicker (components/shared/EventPicker.tsx), not a <select>. Give it the whole list (/api/events/all?includePast=true); it opens on upcoming events, keeps past ones behind "Show past events", and searches all of them. emptyLabel adds a clearable choice ("All events"), graceHours keeps just-ended events current (the scanner uses 12), describe adds detail per row, and defaultEventId(events) picks the next upcoming one. In e2e, pickEvent(page, title) from e2e/fixtures.ts.
  • /api/events/all still returns full events (options, form, sessions) — fine for a few hundred; a slim ?fields= mode would help if a church gets into the thousands.
  • Webhook examples: a new webhook event type needs an entry in EXAMPLES in utils/webhooks.js; tests/webhooks.test.js fails if an example and buildPayload differ in shape.

Known gaps / follow-ups

  • WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp into WAWP (events message + session.status), and only for senders WhatsApp identifies by phone number — a sender known only by a "LID" is logged and ignored (they can still use the link in the message).

  • /api/webhooks/whatsapp (webhookController.handleWhatsappWebhook) is an old empty stub; the real WAWP webhook is /api/whatsapp/webhook. Remove the stub in a later release.

  • There's no log of individual messages sent, so "Download my data" can't include "messages sent to you" yet.

  • At the door, per-session tickets are booked on new registrations only; editing an existing registration leaves its booked sessions as they are (the backend refuses changes to picked lines). "Add & charge" for a session at the scanner isn't built.

  • Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no camera); scans are tested through the API.

  • Kids check-in: the offline desk is tested at the API level (sync, idempotency, code reservation), not in the browser. A family's two parents share one family; linking a second parent is done at the desk ("parents" endpoint) — there's no invite from My account yet.

  • Volunteer rosters: if a volunteer edits their free volunteer registration on the edit page, the volunteer ticket isn't among the public options, so saving drops it (the backend only refuses adding or increasing it). Rare; the place is still in the roster, and a supervisor can give a ticket at the door. Reminders are a single "day before" one (no per-event timing yet).