- 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
46 KiB
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).
- 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).
- 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/v2only.
Working on this repo
- Release routine: branch
feat/<name>→ commit → bump withnpm version X.Y.Z --no-git-tag-versionin.,backendandfrontend→ add the## [X.Y.Z] - dateheading under[Unreleased]in CHANGELOG.md → write the church-facing## X.Y.Zsection in WHATS-NEW.md (plain language;— urgentin the heading for a security or serious fix) →git merge --no-ffintomain→git tag vX.Y.Z→git push origin main <branch> vX.Y.Z. One minor version per feature. - Before tagging: run
npx eslint srcinfrontendover the whole app (the deploy runsnext 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 andgit 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
/docsroute list inbackend/src/app.js(v2 documents itself), and in-app help infrontend/src/content/help/(register new pages inregistry.ts). - Prisma migrations:
npx prisma migrate dev --create-only --name xagainst a local DB. Prisma 7 keeps adding an unrelated_EventNotifyRecipientsprimary-key/index change — delete those two blocks from the generated SQL. It also rewrites the comment inmigration_lock.toml;git checkoutthat file back. NewAdminAuditActionvalues 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 ownecosystem.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 unlessE2E_PAYFAST_MERCHANT_ID,E2E_PAYFAST_MERCHANT_KEYandE2E_PAYFAST_PASSPHRASEare 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 publicBACKEND_URLandPAYFAST_SKIP_IP_CHECK=true. - End-to-end:
npm run test:e2efrom the repo root (first time:npm installandnpx 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 frombackend/withPORT=5100, the e2eDATABASE_URL,JOBS_ENABLED=false,TWO_FACTOR_REQUIRED_ROLES=false, andnpx next dev -p 3100fromfrontend/withNEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100— then runnpx 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.localpeople 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 inbackend/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 thePaymentProviderinterface (documented at the top), provider on/off and order (AppSettingpayment_providers), the R15 minimum, andcreateCheckout. Tests:backend/tests/payments.test.js.backend/src/payments/providers/yoco.js— Yoco: checkouts, webhook signature check, webhook parsing. Keys/webhook admin stays inutils/yoco.js+controllers/yocoController.js.backend/src/payments/webhook.js— shared webhook processing (Payment withprovider, 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-statusjob, 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>.jswith the same interface, add it toPROVIDERSinindex.js, mounthandleProviderWebhook('<id>')at/api/webhooks/<id>(routes/webhookRoutes.js), and give it a settings card infrontend/src/components/admin/PaymentsTab.tsx. New providers start switched off. Payment.methodstays the wallet type (card, apple_pay…) because cashups bucket on it; the provider isPayment.provider.
Where the privacy (POPIA) code lives
backend/src/utils/privacy.js— news opt-in (setNewsPreferences, logged inCommunicationConsent), unsubscribe tokens (HMAC withJWT_SECRET, never expire),filterRecipients(every bulk send goes through it:kind'update' passes, 'news' only to opted-in accounts and not toMessagingOptOutaddresses), 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, andemailEventAttendees/whatsappEventAttendeesineventControllertakekind; scheduled payloads carry it. - WhatsApp STOP:
handleIncomingMessageinwhatsappController.js(WAWPmessageevent on/api/whatsapp/webhook?secret=…; secret in AppSettingWAWP_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
anonymiseUserandexportUserData. - Privacy notice:
utils/privacyNotice.js(built-in sections, placeholders, versions, draft in AppSettingprivacy_notice_draft,noticePromptPending); public pageapp/legal/privacy(renders whatever/api/privacy/noticereturns), adminapp/dashboard/admin/privacy+components/admin/privacy/*, promptcomponents/shared/NoticePrompt.tsx(viaPrivacyPrompts.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 toDEFAULT_SECTIONSmust also add an entry toBUILT_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" (AppSettingprivacy_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').noticePromptPendingis'first'for anyone who never accepted (noticeVersionAcceptednull) and'updated'when a significant version came out later. The e2e seed setsnoticeVersionAccepted: 0so 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 inACCESS_ACTIONS. Operators:Operatortable, seeded in the 1.39.0 migration. - Retention:
utils/retention.js(policies in AppSettingretention_policies,runRetention,preview,noticeLinesfor{retention}), jobretention(03:30). Anything new that stores personal data should get a retention rule (a new entry inPOLICIES+ runner). Inactive = latest ofUser.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.tsruns the retention job on seeded six-year-old accounts (prepare-db.js→retention);privacy-notice.spec.tspublishes a version that needs accepting, then accepts it for all seeded users inafterAllso 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_KEYenv 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 sharesignedPost; AppSettinghub_*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; itsConnectFormis also on the last/setupscreen),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_requestedalert + one email to Joshua); the Hub resolves it when the site's heartbeat reports a newer version. Joshua then runsscripts/deploy.shon the server. - Local testing: run the Hub (
../events-hub, ports 5200/3200), create a licence there, and start Cross Events withHUB_URL=http://localhost:5200andHUB_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,withAllCampusesfor the public list),campusForEvent(what to save on an event),campusAudienceWhere(broadcast audience). Pure parts are tested intests/campuses.test.js.controllers/campusController.js, routes/api/campuses/*. The setting is changed only throughPUT /api/campuses/settings(it's inPRIVATE_KEYSin 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 cachedGET /api/campusesper page load),components/shared/CampusFilter.tsx(CampusFilter,matchesCampus,CampusBadge),CampusAudiencePicker.tsx,components/admin/CampusesTab.tsx,components/events/EventsGrid.tsx(public filter,hope_events_campusin localStorage),app/campus/[slug]/page.tsx. - e2e: the campuses and two events' campuses are seeded in
e2e/support/prepare-db.jswith campuses off (so a pre-built frontend already has them);campuses.spec.tsturns them on and off.
Campus-scoped staff (1.44.0) — adding a staff route
- Every route behind
stafforsupervisormust carry a marker fromutils/campusScope.js, ortests/campusScopeRoutes.test.jsfails: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 withlistWhere(req, path)/withScope(where, extra)(never spread a scopewhereover 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.jsand e2ecampus-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 withutils/encryption.js),checkIn(code per family per occasion viaKidsPickupCode, a free place onEvent.kidsOptionIdthroughticketForChild, aTicketUsageper check-in),checkOut/overrideCheckOut, messages,board, duty, offline snapshot andsyncOffline, and the privacy parts (removeFromFamily,exportFamily,runKidsRetention). Pure parts tested intests/kids.test.js.controllers/kidsController.js+routes/kidsRoutes.js(/api/kids); campus resolverskidsCheckInandkidsAlertinutils/campusScope.js.- Rooms are
AllocationGroups ofEvent.kidsRoomSetId(withminAge,maxAge,ratio). Event ids onEvent(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 inhope_events_kids_offline_<eventId>),components/kids/*(KidsEventPicker,ChildForm,RoomBoard,StickerPanel), pagesapp/dashboard/staff/kids(desk),staff/kids/rooms,supervisor/kids(setup),user/kids. - e2e:
kids.spec.tson 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 aKidsRoomDutywithfromRoster),rosteredForRooms(used by the kids board), messages, thevolunteersreminder job, hours served, and the privacy parts (exportVolunteering,forgetVolunteer). Pure parts tested intests/volunteers.test.js.reservedOptionIds/publicOptionsthere: 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 toreservedOptionIds.controllers/volunteerController.js+routes/volunteerRoutes.js(/api/volunteers); campus resolversservingRoleandvolunteerSlot; sign-ups, new roles and assigning are inreadOnly.js.- Swaps: no approval — accepting moves the slot at once (claimed with an
updateManyonstatus: 'open'so two people can't both take it);tellOrganisersemailsgetEventNotifyEmails(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, pagesdashboard/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.tson 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:
EventModalinapp/dashboard/supervisor/events/page.tsxtakessource(create mode,cloning), fills its state from it (cloneOptionsleaves 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), andmoveAllDatesshifts 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 frombase. - What the form doesn't hold is copied by the server afterwards:
utils/eventClone.js(copyEventSetup: allocation sets/groups exceptkidsRoomSetId, costs for admins only since costs are admin-only) viaPOST /api/events/:id/copy-from. Tests:tests/eventClone.test.js, e2e "copy an event" insupervisor.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, theseriesjob (runAnnounceJob: public, not started,seriesAnnouncedAtnull → claimed, then followers filtered withfilterRecipientsas news),exportFollows. Tests:tests/series.test.js; e2eseries.spec.ts.controllers/seriesController.js+routes/seriesRoutes.js(/api/series, campus-independent: the list filters each series' events by scope). Event create/update takeseriesId; changing it clearsseriesAnnouncedAtso the new series' followers hear.SeriesFollowis personal data: inanonymiseUserandexportUserData. 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 frompayments/webhook.jsrecordSucceededPayment;allocatepays each registration what it owes; first Payment carriesexternalId),notOnSeasonPass(route guard on options edit and coupons),exportPurchases. Tests:tests/seasonPass.test.js; e2e inseries.spec.ts.refreshPricingForRegistrationskips pass registrations;payments/status.jstreats 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.jswraps the Prisma client with$extendsquery 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 throughprisma(updateManyisn't hooked). Hooks only callnotice; never put slow work there. backend/src/utils/webhooks.js— event types, the in-memory queue (notice→flushafter 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(webhooksjob: 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 inbuildPayload, a hook indb.jsif needed, and a line indataShared(operator register text). - Frontend:
components/admin/WebhooksTab.tsx. Tests:tests/webhooks.test.js, e2ewebhooks.spec.ts(runs a receiver on localhost:5199; e2e backend hasWEBHOOKS_ALLOW_PRIVATE).
Demo data (1.48.2)
backend/scripts/set-demo.jsbuilds everything in memory and writes it withcreateManyin one transaction (so no webhooks fire).PROFILESsays 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 toINSERT_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. clearDatatruncates every table except settings and users, but deletes (not truncates) tables thatUserpoints at — TRUNCATE CASCADE would emptyUserwith 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 througheventWhere(scopeFor(req)). Routes:routes/dashboardRoutes.js. A new "something needs doing" count goes inpendingItems(with its link and theorder).- Frontend:
components/dashboard/TodayPanel.tsx,components/shared/CommandPalette.tsx(mounted inapp/dashboard/layout.tsx;CommandPaletteButtonon the dashboards),lib/commandPages.ts— a new page or setting tab should be added there with its roles. - Deep links read
window.location.searchonce on load (no Suspense needed). - Tests:
tests/dashboard.test.js, e2edashboard.spec.ts.
Where bulk actions live (1.49.1)
backend/src/utils/bulkRegistrations.js—runBulkchecks 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(newfilter.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 withresolveOptionPrice, attendees moved, thenupdateRegistrationStatus. - Frontend:
components/admin/RegistrationBulkBar.tsxonapp/dashboard/admin/registrations. Export goes through/api/reports/excel(access-logged). Tests:tests/bulkRegistrations.test.js, e2ebulk.spec.ts.
Saved filters (1.49.2)
backend/src/utils/savedFilters.js(PAGESis the list of pages with saved filters; values are cleaned to simple types) +routes/savedFilterRoutes.js. Removed inanonymiseUser.components/shared/SavedFilters.tsxtakes the page's current filter values and anonApply;skipDefaultwhen the URL already says what to show. Adding it to another list page: add the page toPAGES(and the component'spagetype), 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 barefixed inset-0div — it gives the dialog role, focus trap, Escape and focus return. Radixui/dialogis 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, oraria-label. Icon-only buttons needaria-label. - Text colour:
text-gray-500is the lightest grey for text on white (gray-400fails 4.5:1). Links inside a<p>/<label>are underlined byglobals.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 abeforecursor, and offer a search.components/shared/Pager.tsxrenders "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
AdminAuditActionneeds a label inACTION_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/registrationswithoutpage(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 runsdist/app.jswhendist/build.jsonhas this version; otherwise tsx compiles on the fly (with a warning). tsx and typescript are runtime dependencies for that fallback.npm run devand e2e use tsx, so they never pick up a staledist/. The server issrc/app.js. - Paths:
dist/mirrorssrc/, sopath.join(__dirname, '..', '..', 'public')works from both. Non-code files insrc/aren't copied (the favicon is read fromsrc/). - New code in TypeScript. Convert a JS module when a release touches it; until then import it
through
src/v2/legacy.tswith a typed signature. TypeScript infers loose types from JS (the Prisma client especially), so v2 code takesprismafrom there, typed asPrismaClient. - A v2 endpoint is
defineRoute({ method, path, tag, summary, access, params?, query?, body?, response, handler })insrc/v2/resources/<area>.ts, imported insrc/v2/index.ts. Lists usepageQuery,paged()andpageOf(); single itemssingle(). Dates go out as ISO strings (iso()), so response schemas useisoDate. ThrowApiError(code, message)ornotFound(). 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 declarecampusScope(start-up fails otherwise). - Services (
src/services/*.ts, 1.51.1): business logic both API versions call. ThrowServiceError(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
outcomeand 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 usev1StatusOf(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 runnpm run api:typesin the repo root and commitfrontend/src/lib/api-v2/(tests/v2.test.tsfails while it's stale). - Shared middleware on v2 (1.51.3):
legacyErrorsToV2(mounted on/api/v2before everything) rewrites any{ message, code }error from middleware shared with v1 into the v2 shape, with the old code asreason. New middleware can keep writing{ message, code }. - Deprecating v1: when a v1 endpoint gets a v2 replacement, add it to
SUCCESSORSinsrc/v2/deprecation.ts. - Express 5: route paths use path-to-regexp v8 (no
*,?or regex in paths — use{/:x}for optional parts,/*namefor 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.emptyLabeladds a clearable choice ("All events"),graceHourskeeps just-ended events current (the scanner uses 12),describeadds detail per row, anddefaultEventId(events)picks the next upcoming one. In e2e,pickEvent(page, title)frome2e/fixtures.ts. /api/events/allstill 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
EXAMPLESinutils/webhooks.js;tests/webhooks.test.jsfails if an example andbuildPayloaddiffer 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).