- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
`retention` job: inactive accounts anonymised after 5 years with a
30-day warning (on by default; never staff roles or people with an
upcoming registration), form answers, login history, payer details in
payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
41 KiB
Hope Events — Road to 2.0.0
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0. Each section covers what the feature does, who uses it, how it fits the existing platform, and the edge cases to handle. See FEATURES.md for what already exists, and FEATURES-ROUND-1.md for the previous round (1.12.0 – 1.23.0). Where to pick up: HANDOVER.md.
Theme for 2.0: turn a feature-rich church app into a product that can be sold — trustworthy (POPIA), flexible (multi-day events, several payment providers), and maintainable (tests, CI, typed backend, versioned API).
Status (2026-10-23): all features in this document are approved. Shipped so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events phase A (sessions, late joining, per-session check-in, attendance register, reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B (pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface (Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page; 1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23); 1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page). Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).
Repositories:
| Repo | What |
|---|---|
joshua/hope-events (this repo) |
The church app. Everything in this document except the Hub app itself — including the Hub client (licence step, "Connect to Hub", heartbeat, licence checks) |
joshua/hope-events-hub (new) |
The Hub app (§13): its own versions, CHANGELOG, releases and server |
Contents
- Multi-day events (sessions) ← first
- Payment providers: PayFast
- POPIA toolkit
- Kids check-in with security labels
- Volunteer / serving rosters
- Event series and cloning
- Apple and Google Wallet tickets
- Outbound webhooks
- Admin dashboard, command palette, bulk actions
- Languages (Afrikaans first)
- Accessibility pass (WCAG 2.2 AA)
- Multi-campus (one install per church)
- Central management platform (Hope Events Hub)
- Foundations (platform work)
- Suggested build order
- Decisions
1. Multi-day events (sessions)
One event that runs over several dates, e.g. "Alpha Course — every Wednesday 18:30–20:30 for 5 weeks", a 3-day conference, or a camp with an evening session each day. People register and pay once, get one ticket, and are checked in per session.
Not the same as Event series. A series groups separate events (Camp 2026, Camp 2027) that each have their own registrations. Sessions are dates inside one event.
Who: Supervisors set up the schedule. Users see it on the event page, in their tickets and in their calendar. Staff check in per session.
Event setup
New Schedule section on the event form:
| Setting | Options |
|---|---|
| Schedule type | Single date (today's behaviour) · Multiple sessions |
| Pattern builder | Repeat daily / weekly / every N weeks on chosen weekday(s), start date, time from–to, number of sessions or until date |
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
| Session location | Optional; falls back to the event location |
| Attendance mode | Whole course (one registration covers all sessions) · Pick sessions (see phase B below) |
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
| Late-joiner price | Per event, only when late joining is on: Full price · Pro-rata (price × remaining sessions ÷ total sessions, rounded to the nearest R10, e.g. R183 → R180, R185 → R190) · Fixed late price |
| Check-in window | Per event: Whole day (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
| Session reminders | Per event, off by default: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
| Certificates | Per event, off by default: when on, choose a template and the completion rule (see Certificates) |
The pattern builder is only a helper: it generates rows, then the session list is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping a public holiday is just deleting one row.
How it works
Event.startDate/endDatestay and are kept in sync automatically (first session start, last session end). Everything that already reads them — consent age checks, transfer cut-off, early-bird, feedback timing, ICS, listings, sitemap — keeps working unchanged. Only places that need per-session behaviour are updated.- Public event page: shows the full schedule, with past sessions greyed out and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec".
- Event listings: a multi-session event stays "upcoming / running" until its last session ends, and sorts by its next session.
- Tickets (PDF, email, WhatsApp): list all session dates. One QR code.
- Calendar (ICS): one VEVENT per session (not an RRULE, so edited or skipped sessions are exact).
- Check-in: the scanner auto-selects today's session (on a day with more than one session, the one happening now or next), with a dropdown to override. Whether a scan is accepted depends on the event's check-in window (whole day or session time only). A ticket can be checked in once per session (per person for group tickets). Scanning again in the same session shows "Already checked in for Session 3 at 18:42".
- Manual check-in: supervisors can tick someone as present for a session from the attendance register afterwards (e.g. let in without a scan), logged in the audit log with who marked it.
- Reminders: off by default; when switched on for the event, a reminder goes out before each session (email/WhatsApp, via the scheduled-message worker), e.g. "See you tonight at 18:30".
- Late joiners: when late joining is on, the registration page shows which sessions are left and the late price ("3 of 5 sessions left — R180").
- Changes: moving or cancelling a session after people registered prompts the supervisor to notify registrants (pre-filled broadcast), and updates the ICS/tickets.
Phase B: pick sessions (drop-in)
Second step, released separately. Lets people register for some sessions, e.g. a conference where each day is sold separately or a weekly drop-in class.
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per session, "Full conference pass" covering all.
- Optional capacity per session on top of the option stock limit.
- The ticket stores which sessions it covers; scanning on a session it doesn't cover shows "Not registered for this session" (with "Add & charge" at the door for supervisors).
- Waitlist works per session when sessions have their own capacity.
Reporting
- Attendance register: grid of attendees × sessions with ticks, totals per session, attendance % per person. Export to Excel and PDF.
- Completion filter: "attended at least 4 of 5" — useful for certificates and for follow-up ("missed the last 2 weeks").
- Daily summary email includes "Sessions today".
- Funnel stats unchanged (they are per event).
Certificates
Optional completion certificates (PDF), chosen per event.
- 3–4 built-in templates, e.g. Classic (border, serif), Modern (clean, accent colour bar), Church (logo-led, scripture line), Kids (playful, for children's programmes). All use the site logo and brand colour from Site settings.
- Event settings: Certificates on/off · Template · Title ("Certificate of
Completion") · Body text with placeholders (
{name},{event},{sessionsAttended},{totalSessions},{endDate}) · Signature name and role (optional uploaded signature image) · Completion rule: attended at least N sessions or N % (default: all sessions). - Preview button in the event form renders a sample PDF.
- Who gets one: attendees meeting the completion rule after the last session. Supervisors can issue/withdraw one manually (audit logged).
- Delivery: download from My Events, bulk download (ZIP) from the attendance register, and optional email/WhatsApp send.
- Each certificate has a short verification code + QR linking to a public "certificate valid" page (name, event, date only).
- Generated with
pdfkit, like tickets and receipts (utils/pdfDocs.js). - Also usable on single-date events (completion = checked in).
Edge cases
| Case | Behaviour |
|---|---|
| Delete a session that has check-ins | Blocked; offer Cancel session instead (kept for history, hidden from schedule, excluded from attendance %) |
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
| Ticket transfer | Allowed until the first session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
| Parental consent | Age is measured at the first session (current startDate rule) |
| Medical details deletion | Runs after the last session (end date), as today |
| Feedback form | Sent after the last session |
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
| Timezones | Sessions stored as UTC instants, same as Event.startDate today (see icsUtils.js) |
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
| Kiosk / At the door | Shows the current session; check-in counts against it |
Data model (sketch)
enum ScheduleType { single sessions }
enum AttendanceMode { whole pick }
enum LatePricing { full prorata fixed }
enum CheckInWindow { whole_day session_only }
model Event {
// ...existing fields
scheduleType ScheduleType @default(single)
attendanceMode AttendanceMode @default(whole)
lateJoinEnabled Boolean @default(false)
lateJoinUntilSessionId String?
latePricing LatePricing @default(full)
lateFixedPrice Float?
checkInWindow CheckInWindow @default(whole_day)
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
certificatesEnabled Boolean @default(false)
certificateConfig Json? // template, title, body, signature, completion rule
sessions EventSession[]
certificates Certificate[]
}
model Certificate {
id String @id @default(uuid())
eventId String
attendeeId String?
userId String?
name String
code String @unique // verification code
manual Boolean @default(false)
issuedAt DateTime @default(now())
revokedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
@@index([eventId])
}
model EventSession {
id String @id @default(uuid())
eventId String
label String?
startsAt DateTime
endsAt DateTime
location String?
capacity Int? // phase B
cancelledAt DateTime?
order Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
ticketSessions TicketSession[]
@@index([eventId, startsAt])
}
model TicketUsage {
// ...existing fields
sessionId String? // null on single-date events
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([sessionId])
}
// Phase B only: which sessions a "pick sessions" ticket covers.
model TicketSession {
ticketId String
sessionId String
@@id([ticketId, sessionId])
}
Ticket.isUsed keeps its meaning for single-date events. For session events
it's not used for check-in decisions; "checked in" is per session
(TicketUsage.sessionId).
Plumbing
- Session CRUD + pattern generator in
backend/src/utils/sessions.jswith Jest tests (DST-free but still test month boundaries, skipped dates, "until" vs "count"). - Scan endpoint takes an optional
sessionId; defaults to the current session. - Help content, audit log entries (session created/moved/cancelled), CHANGELOG, FEATURES.md, SETTINGS.md.
2. Payment providers: PayFast
Add PayFast next to Yoco, and let the admin enable one or both in Site settings. At checkout the user picks from the enabled ones. (Shipped in 1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23: two providers are enough.)
Who: Admins configure providers. Users choose at payment. Supervisors reconcile.
Site settings → Payments
| Setting | Options |
|---|---|
| Providers | Yoco · PayFast — each with an Enabled toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (utils/encryption.js, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast |
| Minimum amount | R15 for all providers (one site-wide minimum for online payments) |
Fees are not handled automatically. Payments are recorded at the full amount paid; provider fees are not deducted, estimated or added to the payer's total. Fees are dealt with by people during cashup, as today. There is no "pass fee to payer" option.
"Test connection" button per provider, like the WhatsApp setup.
How it works
- A single PaymentProvider interface in the backend:
createCheckout,handleWebhook(verify signature → mark paid),getTransaction,refund(if the provider supports it via API),listTransactions(for reconciliation). - Yoco is moved behind the same interface first (no behaviour change), then PayFast is added.
Payment.methodgets the provider name;YocoTransactionis generalised toProviderTransaction(providercolumn) so the existing reconciliation page works for all providers.- Refunds: if the provider has no refund API, the supervisor refunds in the provider's portal and records it here (existing manual refund flow).
Edge cases
- Provider disabled while a checkout is in progress → webhook still accepted and processed.
- Duplicate / late webhooks → idempotent on the provider's transaction id.
- Webhook never arrives → "Check payment status" button and a periodic reconcile job.
- Amount mismatch (user edits amount in app) → payment held as "needs review".
- Only enabled providers appear in kiosk / self-service.
- Amount below R15 → online payment options hidden, with a message to pay at the door / contact the organiser.
- Before building each one: get the documentation links from Joshua and confirm current API docs for webhooks, sandbox, refunds and QR deep-link support.
3. POPIA toolkit
Tools that make the platform POPIA-ready out of the box. This is the main selling point for other churches and organisations: "your attendee data is handled properly, and you can prove it."
This is tooling to support compliance, not legal advice. Wording and retention periods should be checked by whoever is the customer's Information Officer.
Who: Admins (and the appointed Information Officer) manage it. Users get self-service privacy tools.
For users (data subjects)
- Privacy centre in My account:
- Download my data — JSON + readable PDF of profile, registrations, payments, tickets, form answers, consents, messages sent.
- Correct my data — edit profile; request correction of locked records.
- Delete my account — creates a deletion request (see below).
- Communication preferences — email / WhatsApp / marketing opt-in with timestamps (extends the existing notification preferences).
- Unsubscribe link in every bulk email and an opt-out keyword for bulk WhatsApp. Opted-out users are skipped automatically in broadcasts.
- Privacy notice acceptance — versioned; users accept the current version at registration, and are asked again when it changes.
For admins
| Tool | What it does |
|---|---|
| Information Officer | Name, contact details, shown on privacy notice and contact page |
| Privacy notice editor | Versioned text, published version history |
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
| Deletion workflow | Admin approves → personal info is anonymised; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
Edge cases
- Deletion of a user with an open registration / upcoming event → request waits until after the event, or the admin cancels and refunds first.
- Anonymised users still count in historic reports ("Deleted user").
- Backups contain deleted data until they age out — stated in the notice; backup retention is one of the retention policies.
- Impersonation and exports must be logged in the access log.
4. Kids check-in with security labels
A secure check-in/check-out flow for children's ministry and youth events. Builds on sessions (§1), attendees, parental consent and allocation groups.
Who: Parents check in children (self-service kiosk or staff desk). Kids-ministry staff run rooms. Supervisors set up rooms.
How it works
- Parent finds their family (phone number / QR from My account) and ticks the children being dropped off. New children can be added on the spot.
- The system prints two labels:
- Child label: name, room, allergy/medical icons, short pickup code.
- Parent label: the same pickup code.
- Check-out requires scanning or typing the matching code. A mismatch alerts the room leader.
- Rooms come from allocation groups (age/grade rules), with ratios (e.g. 1 leader per 5 toddlers) and live room counts.
- Page a parent: room leader taps "Call parent" → WhatsApp/SMS to the parent ("Please come to the Toddlers room").
- Attendance per child per session feeds the attendance register.
Plumbing / edge cases
- Label printing via browser print with label-size CSS (Dymo / Brother label printers, 62 mm and 89×36 mm presets); PDF fallback.
- Allergies and medical info come from the consent/medical fields; respect the medical-info deletion rules.
- Custody notes ("Do not release to …") visible to room leaders only.
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
5. Volunteer / serving rosters
Plan who serves where, for an event or each session.
- Serving roles per event/session: Welcome, Parking, Kids, Sound, Kitchen… with the number of people needed.
- Volunteers sign up for open slots, or supervisors assign people.
- Reminders via email/WhatsApp before their slot; "can't make it" → swap request to others in the role.
- Volunteer check-in on the day (separate from attendee check-in).
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
- Reports: roster sheet per session, fill rate, hours served per person.
6. Event series and cloning
- Clone event: copy an event with its options, variants, early-bird tiers, forms, consent text, feedback form, cost template, allocation groups and sessions (dates shifted to the new start). No registrations.
- Series: group separate events (Camp 2025, Camp 2026; monthly Men's Breakfast) on one series page, with "Next in this series" and a series follow/notify option.
- Year-on-year reports: registrations, income and attendance compared across events in a series.
- Season pass: a bundle across events in a series (extends event bundles).
7. Apple and Google Wallet tickets
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF ticket, in email and in My Events.
- Pass shows event, date (next session for multi-day), location, holder name, QR code.
- Passes update when the event/session changes (time, venue, cancelled).
- Requires an Apple Developer account (pass signing certificate) and a Google Wallet issuer account; both configured in Site settings and optional — hidden when not configured.
8. Outbound webhooks
Let other systems react to what happens in Hope Events.
- Admin adds endpoints and picks events:
registration.created,registration.paid,registration.cancelled,payment.refunded,ticket.checked_in,user.created,event.published… - Payloads signed with HMAC (secret per endpoint), retries with back-off, delivery log with "Resend".
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via Zapier/Make) without building each one. Native integrations can follow later.
9. Admin dashboard, command palette, bulk actions
- Dashboard as supervisors' home: today's events/sessions, check-ins so far, money in (today/week), pending items (unreconciled payments, consent outstanding, waitlist offers, data requests), alerts.
- Command palette (Ctrl+K): jump to any event, user, registration, ticket or setting; quick actions ("New event", "Find payment R450").
- Bulk actions on registration/attendee lists: message, export, mark paid, move option, cancel, allocate.
- Saved filters per user on list pages.
10. Languages (Afrikaans first)
- i18n framework on the frontend (e.g.
next-intl); English + Afrikaans. - User language preference; emails, WhatsApp messages and PDFs sent in the user's language, with per-language templates.
- Event content stays as typed by the supervisor (optional translated title / description fields later).
11. Accessibility pass (WCAG 2.2 AA)
- Audit public pages, registration, payment, My Events and the kiosk.
- Keyboard navigation, focus states, labels, colour contrast (light and dark), screen-reader names for icon buttons, error messages linked to fields.
- Automated
axechecks in the Playwright suite so it doesn't regress.
12. Multi-campus (one install per church)
Decision: one install per church. Each customer gets their own install
and database (the /setup wizard already makes this possible). This gives
full data separation — which also helps the POPIA story — and lets each
church run on a different server. True multi-tenant is out of scope.
Multi-campus is in scope: one church with several campuses inside one install.
Setup
- Site settings → Campuses: off by default. When on, admins add campuses (name, address, contact, optional logo/colour accent, default location for events).
- Single-campus churches never see campus fields anywhere.
How it works
- Events belong to one campus, or to All campuses (church-wide conferences, camps).
- Public site: campus filter on the events list, remembered per visitor;
campus shown on event cards and tickets. Optional campus landing pages
(
/campus/<slug>). - Users can set a home campus (used as the default filter and for broadcasts). Accounts stay shared across campuses — one login for the church.
- Campus-scoped roles: a supervisor or staff member can be limited to one or more campuses (sees and manages only those events, registrations, scanning, cashups). Admins and unscoped supervisors see everything.
- Reports, cashup, dashboard, funnel stats: filter by campus plus a church-wide total.
- Broadcasts: target "everyone at campus X".
- Kids check-in rooms and volunteer roles are per campus.
- Notification recipients per campus (e.g. the campus pastor gets that campus's daily summary).
Edge cases
- Turning campuses off with campus data present → hidden, not deleted.
- Deleting a campus with events → blocked; archive instead.
- Moving an event to another campus → scoped supervisors of the old campus lose access; logged in the audit log.
Data model (sketch)
model Campus {
id String @id @default(uuid())
name String
slug String @unique
address String?
isActive Boolean @default(true)
order Int @default(0)
events Event[]
}
// Event.campusId String? (null = all campuses)
// User.homeCampusId String?
// UserCampusScope { userId, campusId } — empty = unscoped
Running many installs
Because every church is a separate install, a few tools make that manageable:
- Install script: fresh server → Node, Postgres, PM2, reverse proxy,
.env, migrations, then/setupin the browser. - Update script: pull a tagged version, back up the database, run migrations, build, restart; roll back on failure.
- Version and health: current version shown in admin and on a
/healthendpoint (version, DB, jobs, last backup), reported to the Hub.
13. Central management platform (Hope Events Hub)
A separate app, run by Joshua, to see and manage every Hope Events install from one place: who the customers are, which version each runs, whether they're healthy, basic usage stats, and their licence status.
Who: Joshua (and any future support staff). Customers never log in to it.
Separate app, separate repo, separate server
- Its own git repository:
joshua/hope-events-hubon the same Gitea server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and deploys. It is never shipped to churches, so none of its code (customers, licence signing key, other churches' data) ends up on a customer's server. - Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
- What lives where:
- Hub repo: dashboard, customers, licences, licence signing (private
key — only ever on the Hub server),
/api/installs/registerand/api/installs/heartbeatendpoints, alerts. - Hope Events repo: the Hub client — licence step in
/setup, "Connect to Hub" in Site settings, heartbeat job, licence verification with the Hub's public key, read-only mode, banners.
- Hub repo: dashboard, customers, licences, licence signing (private
key — only ever on the Hub server),
- The contract between them (register / heartbeat payloads, licence token
format) is versioned with a
protocolVersionfield and documented in both repos (docs/hub-protocol.md). The Hub accepts the current and previous protocol version, so churches on older versions keep reporting while they wait to update. - Runs on its own server at
events-hub.crosscode.co.za. If the Hub is down, no church install is affected. - Licence keys are generated by the Hub: Joshua clicks "Generate" on a customer and the Hub creates the key (not typed by hand).
- Hope Family Church's own site is the first registered install.
Registration (setup → Hub)
- The
/setupwizard gets a Licence step: the customer enters the licence key Joshua issued (created in the Hub first). - When setup completes, the install sends
POST /api/installs/registerwith: licence key, site name, URL, admin contact, version, server info (Node/Postgres version, OS), timezone, enabled features. - The Hub checks the key, links the install to the customer, and returns an install id + install secret (stored encrypted in AppSettings) and a signed licence token.
- Existing installs (already past setup) get a "Connect to Hub" button in Site settings → Licence.
All later calls are signed with the install secret (HMAC) and go over HTTPS.
Heartbeat (install → Hub)
- Every 15 minutes (job queue) the install POSTs a heartbeat:
- Health: version, uptime, DB OK, job queue OK, last successful backup, disk free, last error count (from Sentry or local logs), pending migrations.
- Basic stats (aggregate counts only): users, active/upcoming events, registrations and tickets (30 days / all time), check-ins, WhatsApp/email sent, enabled payment providers, campuses on/off.
- Income in ranges, never exact amounts: total payments received (30 days / all time) reported as a band — R0–R1 000, R1 001–R5 000, R5 001–R20 000, R20 001–R50 000, R50 001–R100 000, over R100 000. The band is worked out on the install; the exact figure never leaves it.
- Install → Hub only. The install pushes; the Hub never needs to reach into the install, so it works behind firewalls and the Hub has no access to church data.
- No personal information is ever sent (no names, emails, phones, attendee data). Listed in the install's POPIA operator register and privacy notice as "aggregate usage statistics to the software provider".
Hub dashboard
- Installs list: customer, URL, version (flag if behind the latest tag), licence status, last heartbeat (red if > 1 h ago), health status.
- Install detail: health history, stats charts, version history, licence history, notes, contacts.
- Alerts (email/WhatsApp to Joshua): install offline > 1 h, backup failed or older than 48 h, disk low, errors spiking, licence expiring.
- Releases: latest version and changelog; installs see "Update available" in their admin.
- Customers: contact details, plan, licence(s), billing notes.
Licences
| State | What the install does |
|---|---|
| Active | Everything works |
| Grace (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
| Suspended | Read-only: no new events, no new registrations or payments. Existing tickets still scan, and admins can still view and export all data |
| Revoked | Same as suspended, with a permanent banner |
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the install picks up the change on its next heartbeat.
- The licence is a signed token (Ed25519: the Hub signs with its private key, installs only hold the public key). Installs verify it offline, so a Hub outage never switches a church off, and a token can't be faked by editing the database.
- Never a hard kill: a church must always be able to scan tickets for events people already paid for, and get its own data out (POPIA access rights). Suspension blocks new activity only.
- One plan for now. Plan limits (e.g. number of campuses, WhatsApp) can be carried in the token later.
Things to be aware of
- Code runs on the customer's server, so a determined developer could patch out the licence check. Real protection is the contract plus updates and support only being available to valid licences — the licence check keeps honest customers honest.
- The Hub should not offer remote commands (run updates, impersonate, database access) in v1: it's the most valuable target an attacker could hit, so keeping it read-only toward installs keeps every church safe if it is ever compromised. Remote "trigger update" can be revisited later.
- The Hub itself needs 2FA, backups and the same audit log approach.
Data model (Hub, sketch)
model Customer { id, name, contactName, email, phone, notes, licences, installs }
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
model Install { id, customerId, licenceId, name, url, secretHash, version,
lastHeartbeatAt, health Json, registeredAt }
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
// rolled up to daily after 30 days
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
14. Foundations (platform work)
Not visible to users, but this is what makes 2.0 a safe product to sell and keep adding to.
| Item | What |
|---|---|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move setInterval jobs out of index.js into pg-boss (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | registrationController (2.3k lines), eventController, paymentController → services by domain. Done as each feature touches them, not as a big-bang rewrite |
| TypeScript backend | Incremental (allowJs), new code in TS first; share zod schemas/types with the frontend |
| API v2 | /api/v2 with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). /api (v1) kept during the transition |
| Dependencies | Express 5, native fetch instead of node-fetch, password hashing upgrade with rehash-on-login |
Suggested build order
| Phase | Version | Features | Why |
|---|---|---|---|
| 0 | 1.32.0 ✅ | Early-bird ribbon on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | Multi-day events — phase A (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | POPIA toolkit (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.41.0 + Hub v1 | Install/update scripts, /health, licence step in /setup, "Connect to Hub", heartbeat, licence states — plus the Hub app (separate repo) |
Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.42.0 | Multi-campus | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.51.0 | TypeScript backend + OpenAPI + /api/v2 alongside v1 |
The breaking-change groundwork |
| 12 | 2.0.0 | v1 API removed, migration guide, marketing site/docs for selling | The release |
Each phase should add matching help content (frontend/src/content/help/),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
feature.
Decisions (2026-10-22)
| # | Question | Decision |
|---|---|---|
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
| 2 | Late joining and late price | Set per event: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
| 3 | Check-in window | Per event, whole day by default (any time on the session's date); "session time only" as the alternative |
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
| 5 | Session reminders | Off by default, configurable per event |
| 6 | Selling model | One install per church — data separation, any server. No multi-tenant. Multi-campus inside an install is in scope |
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | Hope Events Hub in its own git repo (joshua/hope-events-hub): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
| — | Hub domain | events-hub.crosscode.co.za (2026-10-23) |
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
| — | Income in the heartbeat | Included, as ranges only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
| — | Licence plans | One plan for now (2026-10-23) |
| — | POPIA: message kinds | Bulk messages are either Event updates (to registrants — still sent after unsubscribing) or News & announcements (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
| — | POPIA part 2 split | 1.39.0: privacy notice editor + versions + re-acceptance, operator register, Information Officer on the contact page, consent log, access log. 1.40.0: retention policies + nightly job, breach register, children's-data flags, readiness page. Hub moves to 1.41.0 (2026-10-23) |
| — | POPIA: privacy notice | Today's notice becomes the starting template, one editable box per section; org name, Information Officer, retention periods and the operator list fill in automatically. Publishing saves a numbered version (2026-10-23) |
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, on by default, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming message webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
Still open
Nothing for now.