59 KiB
Handover — where things stand and how to carry on
Last updated: 2026-11-02, after 1.52.2 and Hub 1.6.0; 2.0.0 in progress on release/2.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.52.2 | scripts/deploy.sh stops first (listing them) when files in the install were changed or added by hand; --check runs only that; runs from a copy outside the install too; ALLOW_LOCAL_CHANGES=1 overrides |
| 1.52.1 | API documentation complete: every v2 route documents its body, answer and parameters — the ~300 answered by v1 handlers through documented shapes (src/v2/docs.ts), shown but not enforced, logged outside production when they don't fit; empty bodies say "No body needed". Test tests/v2Docs.test.ts |
| 1.52.0 | Urgent fix + Corrections: required attendee forms no longer leave paid door/manual registrations without tickets — popup with "Fill in later" (Registration.formWaivedAt, POST /api/v2/registrations/{id}/form-later); one form per person on picked sessions; forms with no questions don't hold tickets; kiosk asks for the forms the server says are missing. Admin Corrections page (src/services/corrections.ts, /api/v2/corrections/…): issue missing tickets, pay later (ticketsReleasedAt), make it free (complimentaryDiscount), check in after the event. Migration 20261102120000_corrections. e2e e2e/corrections.spec.ts |
| 1.51.17 | Security: forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | Incoming webhooks and message links on v2 — src/v2/links.ts (/api/v2/webhooks/…, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | Privacy and admin on v2 — resources/adminPrivacy.ts; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
| 1.51.14 | People and messaging on v2 — resources/peopleMessaging.ts; /event-listing for the public pages; defineRoute refuses duplicates |
| 1.51.13 | Kids check-in and volunteers on v2 — resources/kidsVolunteers.ts |
| 1.51.12 | Tickets and attendance on v2 — resources/attendance.ts; frontend apiV2Raw() for string-path helpers |
| 1.51.11 | Money on v2 — src/v2/viaV1.ts (v1Route: a v2 route answered by the v1 handler); resources/money.ts; /docs/v2 collapsed with a filter |
| 1.51.10 | Managing registrations on v2 — src/services/registrationAdmin.ts (the rest of registrationController.js); frontend fetchAllRegistrations(); money split out to 1.51.11 |
| 1.51.9 | Series, season passes, bundles, coupons, campuses on v2 — src/services/series.ts, deals.ts, campuses.ts; reason on v2 errors from service codes; frontend apiV2Data() |
| 1.51.8 | Rest of event set-up on v2: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — src/services/eventDetails.ts, src/services/setup.ts; frontend fetchEvent() and lib/eventSetup.ts; v2 file and upload routes |
| 1.51.7 | Setting events up on v2 (/events/manage, create, PATCH, deactivate, copy-from, notify-recipients) via src/services/events.ts; fetchManagedEvents() for every picker; pg-boss 11 in schema pgboss_v11 |
| 1.51.6 | Command palette lists every report (from ReportCatalog) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (@cj-tech-master/excelts, no dependencies; e2e e2e/excel.spec.ts); backend engines.node >= 22 |
| 1.51.5 | Next.js 16 and React 19.3 (Turbopack), ESLint 10 with Next 16's flat configs (npm run lint = eslint src); React Compiler-era rules as warnings for now; empty middleware.ts removed |
| 1.51.4 | Security and dependency updates: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
| 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.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.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 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.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.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) |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), scripts/deploy.sh |
| 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.32.0 | Early-bird ribbon on event cards |
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).
- 2.0.0 — in progress on
release/2.0(WIP commit per part; tag 2.0.0 only when all of it is in). The parts and their state are in the roadmap's Status table:- ✅ Hub 1.6.0 — the AI proxy (tagged, pushed). Real Claude calls tested 2026-10-30 through a local Hub with Joshua's key: fine, and a fraction of a cent each.
- ✅ AI plumbing on Cross Events (
backend/src/ai/,/api/v2/ai/*, Site settings → AI,<AiButton>,useAiStatus(); roadmap §15 "Progress"). - Next: the five helpers (
backend/src/ai/helpers.ts, their routes and buttons, an eval set, privacy notice text). - Then v1 removed — keep answering the v1 webhook paths and the old links in sent messages
(
src/v2/links.ts): providers and old emails still use them. Then the migration guide. - Marketing site (roadmap §17): a simple static HTML site — Cross Code as the main site, with a Cross Events page; room for a church app and a full church website later. Repo, domain and hosting to decide first.
- Testing AI locally: run the Hub (
../events-hub, port 5200), set Settings → AI there, give a test licence an AI cap, and start Cross Events withHUB_URL=http://localhost:5200.
- AI rules (decided 2026-10-29, roadmap §15): 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. The model is Claude Sonnet 5.5 for every helper (2026-10-30, after a Haiku/Sonnet trial), set on the Hub (Settings → AI, Hub 1.6.1); its list prices live inevents-hub/backend/src/utils/aiModels.js. - Clean-ups before or with 2.0:
- Turn the React Compiler lint rules back to errors (see Next.js 16) — every
screen is on v2 now, but they're still
warninfrontend/eslint.config.mjs. node-fetchis still listed inbackend/package.jsonthough nothing insrc/uses it — remove it.- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still
bcryptjs. Decide whether it's in 2.0.
- Turn the React Compiler lint rules back to errors (see Next.js 16) — every
screen is on v2 now, but they're still
- Later: move from ExcelTS — deprecated in favour of
documonsterby the same author (pre-1.0 in Oct 2026) — once documonster is stable. After 1.51.7 has run everywhere, the oldpgbossschema can be dropped (DROP SCHEMA pgboss CASCADE;).
Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026-10-28).
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 (since Next 16next buildno longer lints — it still fails on TypeScript errors — so lint errors only show up if you run it; keep it at 0 errors). 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: Node 24 LTS on every server (2026-10-29); new installs get it too.
The backend declares
engines.node >= 22. pg-boss 11 (1.51.7) runs in its own schema,pgboss_v11— it can't migrate pg-boss 10'spgbossschema; there's noarchivetable any more (finished runs stay injobuntil they expire). - Deploying:
bash scripts/deploy.sh [tag]on the server (no tag = latest main). It stops first if files in the install were changed or added by hand (--checkto only check;ALLOW_LOCAL_CHANGES=1to go ahead anyway). 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/v2/webhooks/<id>(insrc/v2/links.ts; add the v1 path inroutes/webhookRoutes.jsonly for providers already registered there), 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/v2/webhooks/whatsapp?secret=…— older set-ups use/api/whatsapp/webhookor/api/webhooks/whatsapp, which run the same handler since 1.51.16; 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. (Paths below are the v1 names; each has a v2 successor inSUCCESSORS,src/v2/deprecation.ts.) - 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).
v1 handlers behind v2 routes (1.51.11 –)
v1Route({ …defineRoute options, v1: handler, map? })(src/v2/viaV1.ts) answers a v2 route with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with params/query/body replaced bymap(ctx)— e.g.map: ctx => ({ params: { eventId: ctx.params.id } })where v1 named it:eventId. The same campus middleware goes inmiddleware.- v1 JSON →
{ data }; a 4xx/5xx → the v2 error (v1code→reason); a redirect →{ data: { redirectUrl } }; withfile, whatever was sent or piped → a download. - Body and query schemas are loose (
z.looseObject) and v1 gets the request as sent, so v2 refuses nothing v1 accepted. Give aqueryschema for filters the frontend passes — the typedapiV2only allows declared ones. - Document every v1Route (1.52.1):
documented: { body?, response? }with the shapes fromsrc/v2/docs.ts(D.obj,D.payment,D.message…) — the body it reads and thedatait answers. They go in the OpenAPI document but are not enforced (the handler still gets what was sent and its answer goes out as it is), so a mistake can't break a screen. Outside production a body or answer that doesn't fit is logged as[api/v2] … doesn't match its documented schema— grep the e2e output for it after changing a handler.tests/v2Docs.test.tsfails if a JSON answer is undocumented or a parameter has no description (common query names are described once inQUERY_HELP, path ones inPATH_HELP). An action that takes no input keepsbody: loose; the document says "No body needed". Because the frontend's types come from the document, a documented shape that's wrong usually shows up as a frontend type error too. - Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API doesn't change when it happens.
- Frontend conversions are scripted from
SUCCESSORSand the OpenAPI document (scratch tool, not in the repo); calls with a computed URL are done by hand.
Managing registrations on v2 (1.51.10)
registrationAdmin.tstakes aCall—{ actor, params, body, query, readOnly, ip, listWhere }— and returns what v1 answered, because these handlers were moved nearly as they were (about 1,400 lines). v1 builds it withcallOf(req), v2 withcallFrom(req, user, parts)inresources/registrationsManage.ts. Lists that are campus-scoped callcall.listWhere(), which refuses (500) when a caller didn't give one, so nothing is listed unscoped by mistake.- Errors a JS helper throws without a status were 400s on v1;
as400keeps them 400 on v2 (TypeErrors and the like stay 500s). - The staff list is paged on v2 (up to 200 a page); screens that need everything use
fetchAllRegistrations(token, { unpaid?, userId? }).
Series, deals and campuses on v2 (1.51.9)
apiV2Data(method, path, opts)(lib/apiV2.ts) returnsdatauntyped for screens that keep their own record types:await (apiV2Data("get", "/coupons", { authToken }) as Promise<Coupon[]>). TypeScript can't infer the method and path when a result type is given, so cast instead.- Codes in service errors (
fail(409, message, 'CAMPUS_HAS_EVENTS')) reach v2 clients aserror.reason, and v1 controllers still send them ascode. - Answers that aren't failures (a coupon that doesn't apply) are
200with a flag on v2.
The rest of event set-up on v2 (1.51.8)
- One event:
fetchEvent(id, { authToken, nextOptions })(lib/api.ts, v2GET /events/{id}/full) — never/api/events/:id. Server pages passnextOptionsfor revalidation. - Set-up calls:
lib/eventSetup.ts(ticket options, sections, attachments, costs,uploadImage(), which returns the path to save). - Downloads and uploads on v2:
file: { contentType }+response: fileResponsemakes a route send a file;upload: { field, description }documents a multipart body (the multer step goes inmiddleware; checkreq.multerError/req.filein the handler). eventController.jsstill holds the public list (getEvents), the admin list and the attendee email/WhatsApp senders — all on v2 since 1.51.14 (v1Route).
Setting events up on v2 (1.51.7)
- Staff event lists: use
fetchManagedEvents(token, { past, inactive, closed, campus })(lib/api.ts, v2GET /events/manage), never/api/events/all. - Loose records: staff endpoints that return whole stored records (events now; more to come)
type the core fields and pass the rest through (
z.looseObject), serialised withJSON.parse(JSON.stringify(...))so dates are ISO strings. Public and member endpoints stay strict. - Campus scope on v2: reuse the v1 middleware with
middleware: [scopeEvent({ need: 'manage' })]— its{ message }refusals become v2 errors throughlegacyErrorsToV2. - Route order: fixed paths (
/events/manage) must be defined before/events/:id—eventsManage.tsis imported beforeevents.tsinsrc/v2/index.ts.
Command palette and Excel (1.51.6)
- Palette: reports come from
components/reports/ReportCatalog.ts— add a report there and it appears. A new dashboard page needs a line inlib/commandPages.ts, or an entry inNOT_IN_PALETTEine2e/dashboard.spec.ts(which fails on any page that's neither). - Excel:
require('@cj-tech-master/excelts')— the exceljs API (new ExcelJS.Workbook(),xlsx.write(res)). Reports needkind: 'table'(or'layered') for the main table.
Next.js 16 (1.51.5)
- Turbopack for dev and build (the default). Lint with
npm run lint/npx eslint src(next lintis gone). There's no middleware/proxy file — addsrc/proxy.ts(Next 16's name) only if needed. - React Compiler lint rules are warnings, not errors (
eslint.config.mjs): 181 existing places, mostlyuseEffect(() => { load(); }, [...]). The plan was to fix them as each screen moved onto v2; every screen is on v2 now (1.51.15) but the rules are stillwarn— fix what's left and turn them back to errors before or with 2.0. Don't add new ones. eslint-config-next16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
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 (fetchManagedEvents(token, { past: true }), v2GET /events/managesince 1.51.7); 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. - The managed events list 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
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). -
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).