Pick sessions (frontend), roadmap and handover docs
Event wizard: how people book (whole course / pick sessions), capacity per session and per-option session mode. Session picker on the public registration page, Manual registration and the kiosk; at the door points to Manual registration. Register, tickets and My Events handle picked sessions. Adds docs/ROADMAP-v2.md and docs/HANDOVER.md so work can carry on from another machine, and a pick-sessions e2e test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
# Hope Events — New Features
|
||||
|
||||
Planned features for the next round of development. 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.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Coupon codes](#1-coupon-codes)
|
||||
2. [Waitlist](#2-waitlist)
|
||||
3. [Group tickets](#3-group-tickets)
|
||||
4. [Offline scanning](#4-offline-scanning)
|
||||
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
|
||||
6. [Sponsor a spot](#6-sponsor-a-spot)
|
||||
7. [Ticket transfer](#7-ticket-transfer)
|
||||
8. [Post-event feedback form](#8-post-event-feedback-form)
|
||||
9. [Group / room allocation](#9-group--room-allocation)
|
||||
10. [Registration funnel stats](#10-registration-funnel-stats)
|
||||
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
|
||||
12. [Admin "impersonate user"](#12-admin-impersonate-user)
|
||||
13. [Suggested build order](#suggested-build-order)
|
||||
|
||||
---
|
||||
|
||||
## 1. Coupon codes
|
||||
|
||||
Discount codes applied at registration. The main use case is **staff
|
||||
registering for events** at a reduced or free rate, but codes also work for
|
||||
promos, leaders, and volunteers.
|
||||
|
||||
**Who:** Supervisors create and manage codes. Users enter them at checkout.
|
||||
Supervisors can also apply them in Manual registration, At the door and the
|
||||
kiosk.
|
||||
|
||||
### Code settings
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
|
||||
| Discount type | Percentage · Fixed amount · Comp (100% free) |
|
||||
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
|
||||
| Events | One event, several events, or all events |
|
||||
| Valid from / until | Optional date window |
|
||||
| Max total uses | Optional |
|
||||
| Max uses per user | Optional (default 1) |
|
||||
| Min quantity | Optional (e.g. 4+ tickets) |
|
||||
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
|
||||
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
|
||||
| Active | On / off toggle |
|
||||
|
||||
### How it works
|
||||
|
||||
- The user enters a code on the registration page. It is validated server-side
|
||||
and the discount shows as its own line before payment.
|
||||
- The discount is stored as a **separate discount line on the registration**,
|
||||
with its value **locked at apply time**, the same way early-bird tranches
|
||||
are locked. Pricing refresh never re-prices an applied coupon, even if the
|
||||
code later expires or is disabled.
|
||||
- **Restricted codes** (by user, role or domain) need the user to be logged
|
||||
in. Guest registration can't use them. This stops staff codes leaking in
|
||||
group chats.
|
||||
- **Role-based staff pricing (optional):** a ticket option can also carry a
|
||||
"Staff price" that applies automatically for Staff+ users without a code.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Edit options:** percentage codes recalculate over the new item total.
|
||||
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
|
||||
- **Cancellation / refund:** a cancelled registration releases its use count.
|
||||
- **Discount can't go below R0.** A comp code on a fully covered registration
|
||||
marks it Paid and generates tickets.
|
||||
- **Concurrency:** check use limits inside a transaction so the last use
|
||||
can't be claimed twice.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Invoices and receipts show the code and discount line.
|
||||
- **New report: Discounts.** Uses per code, total value given away, per event.
|
||||
- Finance and profit reports subtract discounts from expected revenue.
|
||||
- **Audit log:** code created, edited, disabled, and manually applied by staff.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Coupon {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
type CouponType // PERCENT | FIXED | COMP
|
||||
value Decimal?
|
||||
stackEarlyBird Boolean @default(false)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxUses Int?
|
||||
maxUsesPerUser Int? @default(1)
|
||||
minQuantity Int?
|
||||
restrictRoles Role[]
|
||||
restrictDomain String?
|
||||
active Boolean @default(true)
|
||||
events CouponEvent[]
|
||||
options CouponOption[]
|
||||
allowedUsers CouponUser[]
|
||||
redemptions CouponRedemption[]
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(cuid())
|
||||
couponId String
|
||||
registrationId String
|
||||
userId String
|
||||
discountAmount Decimal // locked at apply time
|
||||
appliedById String? // staff member if applied manually
|
||||
createdAt DateTime @default(now())
|
||||
releasedAt DateTime? // set on cancellation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Waitlist
|
||||
|
||||
When an event or ticket option is sold out, users can join a waitlist instead
|
||||
of hitting a dead end.
|
||||
|
||||
**Who:** Users join. Supervisors view and manage the queue.
|
||||
|
||||
### How it works
|
||||
|
||||
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
|
||||
- The user picks an option, variant and quantity. Login is required if the
|
||||
event requires login. Otherwise a guest account is created as usual.
|
||||
- When stock frees up (a cancellation, a refund, or a supervisor raising the
|
||||
stock limit), the **next person in line** gets an email/WhatsApp with a
|
||||
**claim link**.
|
||||
- The claim link **reserves the stock for a set time** (default 24 h, set per
|
||||
event). If it isn't used in time, the offer moves to the next person.
|
||||
- A claimed offer goes through normal registration and payment, with
|
||||
early-bird and coupon rules applied as usual.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- A waitlist tab per event shows position, person, option, quantity, joined
|
||||
date and offer status.
|
||||
- Supervisors can manually offer a spot to someone, reorder the queue, or
|
||||
remove an entry.
|
||||
- Supervisors can toggle the waitlist on or off per event.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
|
||||
public site doesn't oversell.
|
||||
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
|
||||
or skip to the next person who fits. This is configurable per event.
|
||||
- Users can leave the waitlist from their dashboard.
|
||||
- Add the waitlist to the Daily summary (count per event).
|
||||
|
||||
---
|
||||
|
||||
## 3. Group tickets
|
||||
|
||||
One payer registers **several named attendees**, each with their own ticket
|
||||
and their own form response. For example, a parent registers three kids for
|
||||
camp.
|
||||
|
||||
**This is optional.** The payer can still choose to receive all tickets
|
||||
themselves.
|
||||
|
||||
### How it works
|
||||
|
||||
- On the registration page, a toggle reads **"Registering for other people?"**
|
||||
- For each attendee: name, plus optional email/phone, plus the event form if
|
||||
there is one.
|
||||
- **Ticket delivery choice per registration:**
|
||||
- **Send all tickets to me** (default), so the payer gets every ticket.
|
||||
- **Send each person their own ticket**, using their email/WhatsApp. If an
|
||||
attendee has no contact details, their ticket falls back to the payer.
|
||||
- Tickets are **individual, named tickets** rather than a quantity ticket, so
|
||||
each one can be checked in, transferred or allocated to a room/group
|
||||
separately.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Works alongside the existing quantity tickets, since an event can allow
|
||||
either.
|
||||
- If an attendee's email/phone matches an existing account, link the ticket
|
||||
to that account (with the same duplicate-account protection as today).
|
||||
- Attendee forms: each attendee has their own response. The payer can fill
|
||||
them in, or each attendee can get a link to fill in their own.
|
||||
- Feeds into parental consent (one consent request per minor attendee) and
|
||||
group/room allocation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Offline scanning
|
||||
|
||||
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
|
||||
|
||||
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
|
||||
> section offline at a time.**
|
||||
> While offline, devices can't see each other's scans. If two devices scanned
|
||||
> the same section offline, the same ticket could be redeemed twice. Locking
|
||||
> each section to a single device removes that risk without needing conflict
|
||||
> resolution.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Before going offline (while online):** a staff member picks an event and
|
||||
section and taps **"Enable offline mode"**.
|
||||
2. The server **locks that section to this device** and records the device ID
|
||||
and the staff member. Other devices see "Offline-locked by [name] on [device]"
|
||||
and can't scan that section, online or offline, until it's released.
|
||||
3. The device **downloads a snapshot** of every ticket valid for that section
|
||||
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
|
||||
4. **While offline:** scans validate against the local snapshot and update
|
||||
remaining quantities locally. Every scan is queued with a timestamp.
|
||||
5. **When back online:** queued scans sync to the server automatically. Any
|
||||
conflicts (for example, a ticket transferred or cancelled while offline)
|
||||
show in a **sync report** for a supervisor to review.
|
||||
6. Staff tap **"Release section"** to end offline mode and unlock the section.
|
||||
|
||||
### Rules and edge cases
|
||||
|
||||
- One section can only be locked to one device at a time. Other sections of
|
||||
the same event can be locked to other devices.
|
||||
- A supervisor can **force-release** a lock if a phone dies or goes missing.
|
||||
Unsynced scans on that device are lost unless the device reconnects later,
|
||||
in which case they sync and are flagged.
|
||||
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
|
||||
after that time won't be on the device, and the scanner shows "Not found in
|
||||
offline list. Check at the door desk."
|
||||
- The snapshot is stored only for the lock's duration and cleared on release.
|
||||
- Check-in confirmation messages to holders are sent after sync, not
|
||||
instantly.
|
||||
- **Audit log:** lock, release, force-release, and sync with scan count.
|
||||
- Built as a **PWA** (service worker caches the scanner page and assets).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parental consent (youth events)
|
||||
|
||||
For youth events, a parent or guardian must give consent before an attendee
|
||||
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
|
||||
to review and give consent.
|
||||
|
||||
### Event setup
|
||||
|
||||
- New event setting: **"Youth event: requires parental consent"**.
|
||||
- Supervisors write the **consent text** (indemnity, activities, photo
|
||||
permission, transport, medical treatment authorisation) in the event wizard.
|
||||
- Optional extra sections on the consent page: **medical info** (allergies,
|
||||
medication, conditions, medical aid), **emergency contact**, and **photo
|
||||
consent** (Yes/No, recorded separately).
|
||||
|
||||
### How it works
|
||||
|
||||
1. During registration, each attendee gives their **date of birth**. If they
|
||||
are under 18, **parent/guardian name, email and phone** are required.
|
||||
2. On registration, the parent gets an **email and a WhatsApp** (always both,
|
||||
regardless of anyone's notification preference) with a unique, secure
|
||||
consent link.
|
||||
3. The consent page shows the event details, attendee name and consent text.
|
||||
The parent fills in any medical/emergency fields, **types their full name**
|
||||
as a signature, ticks "I am the parent/legal guardian", and taps
|
||||
**Give consent** (or **Decline**).
|
||||
4. The system records the timestamp, IP address, device, typed name and the
|
||||
**version of the consent text** they agreed to.
|
||||
5. The registrant and the event's notify recipients get a confirmation.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Tickets are generated but marked "Consent pending"** until consent is
|
||||
given. Scanners show a clear warning and block check-in for that ticket
|
||||
(supervisors can override, with an audit log entry).
|
||||
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
|
||||
3 days until the event, or until consent is given or declined.
|
||||
- **Declined consent:** notifies the registrant and supervisors. The
|
||||
registration stays but can't be checked in. A supervisor handles the refund.
|
||||
- **Consent links expire** at the event's end date, and can be resent from
|
||||
the registration detail screen.
|
||||
- If the consent text is edited after some parents have consented, they keep
|
||||
their record of the old version. Supervisors can choose to re-request
|
||||
consent.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- **Consent status column** on registrations and At the door (Pending /
|
||||
Given / Declined).
|
||||
- **New reports:** Consent status per event, and **Medical & emergency list**
|
||||
(printable, for camp leaders). Access to the medical list is restricted to
|
||||
Supervisor+ and logged.
|
||||
- Bulk "Resend consent request" to everyone still pending.
|
||||
|
||||
### Privacy
|
||||
|
||||
Under POPIA, processing a child's personal information needs the consent of a
|
||||
competent person (parent/guardian). Medical data is special personal
|
||||
information. Store it encrypted, restrict access, include it in the existing
|
||||
anonymise/delete flows, and delete it a set period after the event.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sponsor a spot
|
||||
|
||||
A public donation flow linked to a specific event, so people can fund a spot
|
||||
for someone who can't afford it (bursaries).
|
||||
|
||||
### How it works
|
||||
|
||||
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
|
||||
spot"** button on the event page.
|
||||
- Sponsors choose: **one full spot** (the main ticket price), **multiple
|
||||
spots**, or **a custom amount**. Payment goes through Yoco as usual.
|
||||
- A sponsor can be anonymous, or add a name and message.
|
||||
- The money is recorded as a **donation tagged to the event**, and lands in a
|
||||
**sponsorship pool** for that event.
|
||||
- Supervisors assign pool funds to registrations using the existing donation
|
||||
assign/unassign flow, fully or partly.
|
||||
- An optional public progress bar shows "12 of 20 spots sponsored".
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Sponsors get a receipt. They can optionally get a thank-you after the event
|
||||
through Automations.
|
||||
- The Donations breakdown report gains a sponsorship pool column (received,
|
||||
assigned, remaining) per event.
|
||||
- Recipients' identities are never shown to sponsors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticket transfer
|
||||
|
||||
Users can hand their ticket to someone else before the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- From My Events, a user taps **Transfer** on a ticket, and enters the new
|
||||
holder's name and email/phone.
|
||||
- The new holder gets the ticket by email/WhatsApp. If they don't have an
|
||||
account, an inactive one is created with an activation link (same as guest
|
||||
registration).
|
||||
- The **old QR code is invalidated** and a new one is issued, so screenshots
|
||||
of the old ticket won't scan.
|
||||
- The original payer stays responsible for the registration (billing doesn't
|
||||
move).
|
||||
|
||||
### Rules
|
||||
|
||||
- Allowed only before the event starts, and only for unredeemed tickets
|
||||
(partially redeemed quantity tickets can't be transferred).
|
||||
- Can be disabled per event. Supervisors can always transfer.
|
||||
- If the event needs a form or parental consent, the new holder must complete
|
||||
their own.
|
||||
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
|
||||
flags any scan of a transferred ticket's old code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-event feedback form
|
||||
|
||||
An automatic survey sent after the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- In the event wizard, a new **Feedback** step reuses the form builder, plus
|
||||
new field types: **Rating (1–5 stars)** and **Multiple choice**.
|
||||
- Supervisors pick a **send time** (e.g. the morning after the event ends).
|
||||
It's sent by the existing scheduled job to everyone who **checked in**, or
|
||||
to all registrants, as chosen.
|
||||
- Responses can be anonymous or named (per event setting).
|
||||
- One automatic reminder after 3 days for anyone who hasn't responded.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **New report: Feedback summary.** Response rate, average rating per
|
||||
question, multiple-choice breakdown (bar charts), and a list of text
|
||||
answers. Exports to PDF/Excel like other reports.
|
||||
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
|
||||
|
||||
---
|
||||
|
||||
## 9. Group / room allocation
|
||||
|
||||
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
|
||||
camps and lockdowns.
|
||||
|
||||
### How it works
|
||||
|
||||
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
|
||||
groups", "Buses". An event can have several sets.
|
||||
- Each set has **groups** with an optional **capacity**, an optional
|
||||
**gender restriction** and an optional **leader** (a staff user).
|
||||
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
|
||||
right, with capacity counters.
|
||||
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
|
||||
optionally keeping registrations together (siblings) or splitting them
|
||||
(friends).
|
||||
- Works per attendee, so it relies on group tickets (named attendees).
|
||||
|
||||
### Outputs
|
||||
|
||||
- Printable lists per group (with leader), and a master list.
|
||||
- Optionally show the group/room on the attendee's ticket and in My Events.
|
||||
- Group leaders (staff) can see their group's list, including medical
|
||||
info if they're allowed to.
|
||||
- Bulk message by group ("Bus 2 leaves at 07:30").
|
||||
|
||||
---
|
||||
|
||||
## 10. Registration funnel stats
|
||||
|
||||
Shows where people drop off between seeing an event and paying.
|
||||
|
||||
### Funnel stages (per event)
|
||||
|
||||
1. **Event page views** (unique visitors)
|
||||
2. **Registration started** (opened the registration page)
|
||||
3. **Registration submitted**
|
||||
4. **Paid** (fully paid or free)
|
||||
|
||||
### How it works
|
||||
|
||||
- Lightweight first-party tracking (no third-party analytics). Log page views
|
||||
and stage events with an anonymous session ID. No personal data until the
|
||||
user logs in or registers.
|
||||
- **New report: Registration funnel.** Count and conversion % at each stage,
|
||||
over a date range, with a daily trend chart.
|
||||
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
|
||||
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
|
||||
- A dashboard tile shows conversion % for active events.
|
||||
|
||||
### Privacy
|
||||
|
||||
Respect cookie consent. Aggregate raw page-view data after 90 days.
|
||||
|
||||
---
|
||||
|
||||
## 11. 2FA for Admins and Supervisors
|
||||
|
||||
Two-factor authentication, **required** for Admin and Supervisor accounts.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
|
||||
Authenticator). WhatsApp code can be offered as a backup method since WAWP
|
||||
is already set up.
|
||||
- **Enrolment:** on the next login after being given the Supervisor/Admin
|
||||
role, the user must set up 2FA by scanning a QR code and confirming a code.
|
||||
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
|
||||
- **Remember this device** for 30 days (optional, per login).
|
||||
- Optional for Staff and User roles, from Profile & Security.
|
||||
|
||||
### Admin controls
|
||||
|
||||
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
|
||||
their sessions and forces re-enrolment.
|
||||
- Security event log entries: 2FA enabled, disabled, reset, recovery code
|
||||
used, failed code attempts.
|
||||
- Failed 2FA attempts count toward the existing login lockout.
|
||||
- The setup wizard enrols the first admin in 2FA.
|
||||
|
||||
---
|
||||
|
||||
## 12. Admin "impersonate user"
|
||||
|
||||
Lets an Admin see the site exactly as a specific user sees it, to debug
|
||||
problems ("my ticket isn't showing").
|
||||
|
||||
### How it works
|
||||
|
||||
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
|
||||
**reason** (required).
|
||||
- A short-lived session is created (max 30 minutes) that acts as the user.
|
||||
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
|
||||
|
||||
### Safety rules
|
||||
|
||||
- **Read-only by default.** Payments, refunds, cancellations, password/email
|
||||
changes, deleting data, giving consent and sending messages are blocked
|
||||
while impersonating.
|
||||
- Can't impersonate another Admin.
|
||||
- Requires 2FA to be active on the admin's account.
|
||||
- **Audit log:** start, end, reason, and every page/action during the
|
||||
session.
|
||||
- Optional: notify the user that an admin viewed their account (an
|
||||
organisation-level setting).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Features | Why |
|
||||
|-------|----------|-----|
|
||||
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
|
||||
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
|
||||
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
|
||||
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
|
||||
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
|
||||
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
|
||||
SETTINGS.md.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.35.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Done so far (road to 2.0)
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
|
||||
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
|
||||
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
|
||||
provider transaction table; Site settings → Payments with an Enabled toggle per
|
||||
provider and display order. Decisions: fees are **never** deducted or passed on
|
||||
(people handle fees at cashup); minimum online payment **R15** for all providers.
|
||||
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
|
||||
current API documentation links from Joshua before building each one, and don't
|
||||
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
|
||||
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## 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 → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **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).
|
||||
- **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).
|
||||
- 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.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
|
||||
## 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/`.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- The at-the-door booking pop-up doesn't have the session picker; per-session tickets
|
||||
are booked from Manual registration (the page says so).
|
||||
- The waitlist works per ticket option, not per session.
|
||||
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
|
||||
camera); scans are tested through the API.
|
||||
@@ -0,0 +1,774 @@
|
||||
# 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). **Next: 1.36.0 payment provider interface (Yoco
|
||||
moved behind it), then PayFast, SnapScan, Zapper.**
|
||||
|
||||
**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, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
|
||||
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, SnapScan, Zapper
|
||||
|
||||
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
|
||||
### Site settings → Payments
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Providers | Yoco · PayFast · SnapScan · Zapper — 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 · SnapScan · Zapper |
|
||||
| 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, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
- `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/domain (e.g. `hub.<your-domain>`). If the Hub is
|
||||
down, **no church install is affected**.
|
||||
- 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.
|
||||
- **Optional (off by default, customer chooses):** payment totals.
|
||||
- **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.
|
||||
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
|
||||
the token.
|
||||
|
||||
### 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.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.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.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.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 · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| — | 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 |
|
||||
|
||||
## Still open
|
||||
|
||||
Hub (needed before phase 6, not before 1.33.0):
|
||||
|
||||
1. Hub domain and server, and where licence keys come from (created by hand
|
||||
in the Hub per customer?).
|
||||
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
|
||||
still scan, data export still works) — agreed?
|
||||
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
|
||||
counts only?
|
||||
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
|
||||
now?
|
||||
Reference in New Issue
Block a user