Files
hope-events/docs/ROADMAP-v2.md
T
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- 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
2026-10-23 20:25:58 +02:00

788 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../FEATURES.md)
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./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
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
6. [Event series and cloning](#6-event-series-and-cloning)
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work)
15. [Suggested build order](#suggested-build-order)
16. [Decisions](#decisions-2026-10-22)
---
## 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](#6-event-series-and-cloning). 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](#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` / `endDate` **stay** 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)
```prisma
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.js` with
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.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) 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 `axe` checks 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)
```prisma
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 `/setup` in 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 `/health`
endpoint (version, DB, jobs, last backup), reported to the
[Hub](#13-central-management-platform-hope-events-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-hub` on 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/register` and
`/api/installs/heartbeat` endpoints, 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.
- **The contract between them** (register / heartbeat payloads, licence token
format) is versioned with a `protocolVersion` field 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)
1. The `/setup` wizard gets a **Licence** step: the customer enters the
**licence key** Joshua issued (created in the Hub first).
2. When setup completes, the install sends `POST /api/installs/register`
with: licence key, site name, URL, admin contact, version, server info
(Node/Postgres version, OS), timezone, enabled features.
3. 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**.
4. 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)
```prisma
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.