Files
hope-events/docs/HANDOVER.md
T
joshuaandClaude Opus 5.5 a7c8148588 Season passes (1.47.3)
A pass sells a ticket at each of several events in a series for one
price. Supervisors set passes up on the Event series page (events and
ticket per event; fixed once sold). Buying on the series page registers
the buyer at every event at once with one checkout (free passes give
tickets straight away; Pay now if online payment isn't possible). The
price is split over the events in proportion to their normal prices:
each registration line is priced at its share and the webhook records
one Payment per event, so cashups stay per event. Pass registrations
aren't repriced, take no coupons and can't be edited. Migration
20261028150000_season_pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:19:58 +02:00

30 KiB

Handover — where things stand and how to carry on

Last updated: 2026-10-28, after 1.47.3 (and Hub 1.4.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.47.3 Season passes (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration 20261028150000_season_pass
1.47.2 Series: year on year report (§6 part 3): GET /api/series/:id/compare (compareSeries in utils/series.js), report seriesComparison in ReportsV2, "Compare" on the Event series page
1.47.1 Event series (§6 part 2): Event series page, Series box on events, public /series/<slug>, "Next in this series", follow (News & announcements email from the series job when a new event goes live). Migration 20261028140000_event_series
1.47.0 Copy an event (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by POST /api/events/:id/copy-from. No registrations, coupons, attachments, kids check-in or volunteer roles
1.46.0 Volunteer rosters (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly
1.46.1 Removed the Gitea CI workflow (no runner): test, lint and deploy by hand
1.46.2 Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page)
1.45.1 Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves
1.45.0 Kids check-in (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children
1.44.0 Campus-scoped staff (§12 part 2): Admin → Users "Limit to campuses"; utils/campusScope.js on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test tests/campusScopeRoutes.test.js
1.43.0 Campuses (§12 part 1): Site settings → Campuses (off by default), events per campus or all campuses, public campus filter (remembered, starts on home campus) and /campus/<slug> pages, home campus on the profile, campus filters on Reports and the dashboards, "Everyone at a campus" in broadcasts, campus summary recipients, campus on ticket PDFs, heartbeat campusCount. Also adds the hub_update_requested audit action missing since 1.42.0
1.42.1 Production Hub public key built in (publicKey.js); HUB_PUBLIC_KEY only needed for a local Hub
1.42.0 Request update instead of the deploy command: Site settings → Licence sends a signed update request to the Hub (protocol §3a), which opens an alert and emails Joshua; "Update requested on …" until the site runs that version, and the admin update banner hides meanwhile. Audit action hub_update_requested. Needs Hub 1.1.0
1.41.3 Renamed Hope Events → Cross Events in all UI text and docs (technical identifiers unchanged, see above)
1.41.2 Background jobs page: last-run times were 2 h ahead (Prisma's pg adapter drops the offset of timestamptz in raw queries — read pg-boss times AT TIME ZONE 'UTC'); APP_TIMEZONE documented
1.41.1 Licence fixes — no licence (never connected, or the Hub answers INSTALL_NOT_FOUND, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; LICENCE_CHECK_DISABLED for e2e
1.41.0 Cross Events Hub connection — licence step in /setup and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), /health, "update available", scripts/install.sh. The Hub itself is joshua/events-hub 1.0.0
1.40.0 POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly retention job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, {retention} in the notice
1.39.0 POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page
1.38.0 POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising
1.32.0 Early-bird ribbon on event cards
1.33.0 Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page)
1.34.0 Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, scripts/deploy.sh
1.35.0 Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity)
1.37.4 Deploy script: no tag = latest main; per-install ecosystem.config.js out of git (ecosystem.config.example.js)
1.37.3 Fix: the production build failed on unescaped quotes in a help text
1.37.2 Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection
1.37.1 "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap
1.37.0 PayFast (signed form, ITN checks), ProviderCheckout with amount checks, the "Pay online" page (/pay/<id>) where payers choose a provider
1.36.0 Payment provider interface (backend/src/payments) with Yoco behind it, ProviderTransaction, Payment.provider, Site settings → Payments (on/off and order per provider), R15 minimum
1.35.1 Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts)

Next up

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

  1. 1.48.0 — outbound webhooks (§8).

Working on this repo

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

Tests

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

Where the multi-day code lives

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

Where the payment code lives

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

Where the privacy (POPIA) code lives

  • backend/src/utils/privacy.js — news opt-in (setNewsPreferences, logged in CommunicationConsent), unsubscribe tokens (HMAC with JWT_SECRET, never expire), filterRecipients (every bulk send goes through it: kind 'update' passes, 'news' only to opted-in accounts and not to MessagingOptOut addresses), email/WhatsApp footers, STOP handling, data requests, deletionBlockers, anonymiseUser, exportUserData + PDF. Tests: backend/tests/privacy.test.js; e2e: e2e/privacy.spec.ts.
  • controllers/privacyController.js + routes/privacyRoutes.js (/api/privacy).
  • Bulk sends: broadcastController, whatsappBroadcastController, and emailEventAttendees / whatsappEventAttendees in eventController take kind; scheduled payloads carry it.
  • WhatsApp STOP: handleIncomingMessage in whatsappController.js (WAWP message event on /api/whatsapp/webhook?secret=…; secret in AppSetting WAWP_WEBHOOK_SECRET).
  • Frontend: app/dashboard/user/privacy, app/unsubscribe, app/dashboard/admin/data-requests, components/shared/NewsPrompt.tsx (dashboard layout), MessageKindPicker.tsx.
  • Anything new that stores personal data must be added to anonymiseUser and exportUserData.
  • Privacy notice: utils/privacyNotice.js (built-in sections, placeholders, versions, draft in AppSetting privacy_notice_draft, noticePromptPending); public page app/legal/privacy (renders whatever /api/privacy/notice returns), admin app/dashboard/admin/privacy + components/admin/privacy/*, prompt components/shared/NoticePrompt.tsx (via PrivacyPrompts.tsx: notice first, then the news question). Changing the built-in text changes the live notice only for installs that haven't published a version yet.
  • Access log: utils/accessLog.js (logDataAccess(req, { action, what, eventId, records })). Any new bulk export or list of personal data should call it and get a label in ACCESS_ACTIONS. Operators: Operator table, seeded in the 1.39.0 migration.
  • Retention: utils/retention.js (policies in AppSetting retention_policies, runRetention, preview, noticeLines for {retention}), job retention (03:30). Anything new that stores personal data should get a retention rule (a new entry in POLICIES + runner). Inactive = latest of User.createdAt, lastLoginAt (set on login), registrations/event end, attendee rows, payments. Readiness: utils/readiness.js. Breaches: SecurityBreach. Admin panels: components/admin/privacy/{Readiness,Retention,Breaches}Panel.tsx.
  • e2e: privacy-retention.spec.ts runs the retention job on seeded six-year-old accounts (prepare-db.js → retention); privacy-notice.spec.ts publishes a version that needs accepting, then accepts it for all seeded users in afterAll so later spec files don't meet the prompt.

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

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

Where campuses live (1.43.0)

  • backend/src/utils/campuses.js: the setting (campuses_enabled, off by default), campusWhere (list filters: one campus, 'all' = church-wide only, withAllCampuses for the public list), campusForEvent (what to save on an event), campusAudienceWhere (broadcast audience). Pure parts are tested in tests/campuses.test.js.
  • controllers/campusController.js, routes /api/campuses/*. The setting is changed only through PUT /api/campuses/settings (it's in PRIVATE_KEYS in the settings controller).
  • While campuses are off, every campus field and filter is ignored (the data stays). New code should check campusesEnabled() the same way.
  • Frontend: hooks/useCampuses.ts (one cached GET /api/campuses per page load), components/shared/CampusFilter.tsx (CampusFilter, matchesCampus, CampusBadge), CampusAudiencePicker.tsx, components/admin/CampusesTab.tsx, components/events/EventsGrid.tsx (public filter, hope_events_campus in localStorage), app/campus/[slug]/page.tsx.
  • e2e: the campuses and two events' campuses are seeded in e2e/support/prepare-db.js with campuses off (so a pre-built frontend already has them); campuses.spec.ts turns them on and off.

Campus-scoped staff (1.44.0) — adding a staff route

  • Every route behind staff or supervisor must carry a marker from utils/campusScope.js, or tests/campusScopeRoutes.test.js fails:
    • scopeEvent({ resolve, from, need, ownerOk, nameCampus }) — one event, or a record traced to one (RESOLVERS: registration, ticket, ticketQr, payment, eventOption, optionVariant, bundle, allocationSet/Group, waitlistEntry, scanLock, syncReport, section, certificate, consentRequest). need: 'manage' for setup, cashup and refunds (church-wide events refused).
    • scopeBody([[field, resolver], …], { need, orphan }) — records named in the body (lists of ids too); orphan: 'deny' when there must be an event (payments).
    • scopeList — the controller filters with listWhere(req, path) / withScope(where, extra) (never spread a scope where over one that has its own OR) or checks itself.
    • unscopedOnly, campusIndependent('reason'), scopeBroadcast({ freeformField }).
  • Routes open to the owner and staff (no role middleware, so the test can't see them) need scopeEvent({ …, ownerOk: true }) by hand — registrations, payments, tickets, consent requests and coupon validation have it.
  • Rules: admins never scoped; scopes ignored while campuses are off; another campus → 404, except the scanner (403 naming the campus). Tested in tests/campusScope.test.js and e2e campus-scoped.spec.ts (staff and supervisor scoped to E2E South; a seeded North ticket).

Where kids check-in lives (1.45.0)

  • backend/src/utils/kids.js — everything: codes (randomCode, normaliseCode), occasions (occasionFor: today's session, or the day), room suggestion and status, families and children (allergies/medical/custody encrypted with utils/encryption.js), checkIn (code per family per occasion via KidsPickupCode, a free place on Event.kidsOptionId through ticketForChild, a TicketUsage per check-in), checkOut / overrideCheckOut, messages, board, duty, offline snapshot and syncOffline, and the privacy parts (removeFromFamily, exportFamily, runKidsRetention). Pure parts tested in tests/kids.test.js.
  • controllers/kidsController.js + routes/kidsRoutes.js (/api/kids); campus resolvers kidsCheckIn and kidsAlert in utils/campusScope.js.
  • Rooms are AllocationGroups of Event.kidsRoomSetId (with minAge, maxAge, ratio). Event ids on Event (kidsOptionId, kidsRoomSetId) are plain strings, checked in code.
  • Kids check-in isn't blocked in licence read-only mode, on purpose: it's like scanning tickets, and children's safety at pickup comes first.
  • Frontend: lib/kids.ts (types, room suggestion, offline copy in hope_events_kids_offline_<eventId>), components/kids/* (KidsEventPicker, ChildForm, RoomBoard, StickerPanel), pages app/dashboard/staff/kids (desk), staff/kids/rooms, supervisor/kids (setup), user/kids.
  • e2e: kids.spec.ts on the seeded hidden "E2E Kids Church" (set up, parent adds a child, desk checks in, parent sees the code, wrong code then the right one, room alert).

Where volunteer rosters live (1.46.0)

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

Where copying an event lives (1.47.0)

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

Where event series live (1.47.1)

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

Where season passes live (1.47.3)

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

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.

  • The audit-log page's action filter list was already missing newer actions before 1.38.0 (only the three privacy ones were added).

  • 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).