Compare commits

..
Author SHA1 Message Date
joshuaandClaude Opus 5.5 b441ffac60 Bump to 1.35.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:25:32 +02:00
joshuaandClaude Opus 5.5 6010641629 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>
2026-10-23 09:25:21 +02:00
joshuaandClaude Opus 5.5 e338768f6e Pick sessions (backend): book individual sessions with per-session capacity
Options on a multi-day event can be bought per session (one line and
ticket per session, priced per session, bookable until the session
starts). Sessions can have a capacity covering picked places and
whole-course tickets. Picked tickets only scan on their own session; the
register, certificates and reminders follow the sessions booked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:05:48 +02:00
joshua cc44e62379 Merge branch 'feat/ci-e2e-jobs'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-22 15:50:00 +02:00
joshuaandClaude Opus 5.5 5aba2e735f Bump to 1.34.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:49:56 +02:00
joshuaandClaude Opus 5.5 48660732fe Run background jobs on a pg-boss queue; add e2e tests, CI and deploy script
Timed jobs move from in-process timers to a job registry run by pg-boss:
once per schedule across processes, retries, run history, cron schedules
in the church timezone, and an admin Background jobs page with Run now.
Playwright end-to-end tests run against their own seeded database. A
Gitea Actions workflow runs backend tests, frontend checks and the e2e
tests, and deploys v* tags with scripts/deploy.sh once secrets are set.
Also fixes the at-the-door check-in count for multi-day tickets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:49:46 +02:00
joshua 8cb2755493 Merge branch 'feat/multi-day-sessions' 2026-10-22 14:54:03 +02:00
joshuaandClaude Opus 5.5 e84d390393 Bump to 1.33.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:53:59 +02:00
joshuaandClaude Opus 5.5 39558577f7 Add multi-day events (frontend), attendance register and certificates
Event wizard: one date or multiple sessions with a repeat-pattern builder,
and a new Attendance step (late joining and pricing, check-in window,
session reminders, certificates with preview). Public pages show the
schedule and late prices; the scanner picks today's session; new
attendance register page, My Certificates, and a public verify page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:53:39 +02:00
joshuaandClaude Opus 5.5 c79b74bc67 Add multi-day event sessions (backend)
Events can run over several sessions: per-session check-in with a whole-day
or session-time window, late joining with pro-rata or fixed late pricing,
an attendance register (Excel/PDF), session reminders, and completion
certificates (four templates, public verify page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:20:52 +02:00
joshua 6a8ef62630 Merge branch 'feat/early-bird-ribbon' 2026-10-22 13:48:29 +02:00
joshuaandClaude Opus 5.5 dbac248d8f Bump to 1.32.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:28 +02:00
joshuaandClaude Opus 5.5 b1af901d6a Show an early-bird ribbon on event cards
Event cards on the home page and events list get a diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open. GET /api/events returns earlyBirdEndsAt per event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:22 +02:00
joshua 0bddb329e3 Merge branch 'feat/google-site-verification' 2026-10-22 11:43:01 +02:00
joshuaandClaude Opus 5.5 be4b6f02ea Bump to 1.31.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:43:00 +02:00
joshuaandClaude Opus 5.5 880e3206cc Add Google Search Console verification setting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:42:56 +02:00
joshuaandClaude Opus 5.5 7b2bfc31e6 Merge branch 'feat/seo-and-traffic-sources'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:17 +02:00
joshuaandClaude Opus 5.5 963c1824bf Bump to 1.31.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:15 +02:00
joshuaandClaude Opus 5.5 38262022ef Add event structured data, canonical URLs and referrer-based traffic sources
- Event pages emit schema.org Event JSON-LD (dates, venue, image,
  organizer, ZAR offers, sold-out status) for Google event results.
- Event pages, the events list and home page set a canonical URL so
  tagged and alias links don't split rankings.
- Funnel tracking falls back to the referring site (Facebook, Instagram,
  Google, ...) and fbclid/gclid when a link has no utm_source/src.
- Share and Save QR on the event page tag their links as share / qr.
- getServerSettings moves to lib/serverSettings.ts for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:02 +02:00
97 changed files with 7444 additions and 502 deletions
+129
View File
@@ -0,0 +1,129 @@
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
#
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
# https://docs.gitea.com/usage/actions/act-runner
name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
env:
NODE_VERSION: "20"
# Throwaway values for the test database and tokens — never production secrets.
JWT_SECRET: ci-only-jwt-secret-not-for-production
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
working-directory: backend
- name: Migrations apply cleanly
run: npx prisma migrate deploy
working-directory: backend
- run: npm test
working-directory: backend
frontend:
name: Frontend lint, typecheck, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npx eslint src
working-directory: frontend
- run: npx tsc --noEmit -p .
working-directory: frontend
- run: npm run build
working-directory: frontend
env:
NEXT_PUBLIC_API_URL: http://localhost:5000
NEXT_TELEMETRY_DISABLED: "1"
e2e:
name: End-to-end tests
runs-on: ubuntu-latest
needs: [backend, frontend]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm ci
working-directory: backend
- run: npm ci
working-directory: frontend
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
CI: "true"
- name: Keep the report when tests fail
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |
e2e/report
e2e/test-results
retention-days: 7
deploy:
name: Deploy tagged release
runs-on: ubuntu-latest
needs: [backend, frontend, e2e]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
TAG: ${{ github.ref_name }}
run: |
if [ -z "$DEPLOY_HOST" ]; then
echo "DEPLOY_HOST isn't set — skipping the deploy."
exit 0
fi
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
+6 -1
View File
@@ -39,4 +39,9 @@ hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
backend/data/banner.json
backend/data/banner.json
# end-to-end tests
e2e/.data/
e2e/report/
e2e/test-results/
frontend/.next-e2e/
+74
View File
@@ -7,6 +7,80 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.35.0] - 2026-10-23
### Added
- **Pick sessions.** A multi-day event can let people choose which sessions to come to — day passes for a conference, or a weekly drop-in class. In the event wizard choose "How people book: Pick sessions", then in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want when they register; each session is its own line and its own ticket, at the per-session price (early-bird and staff prices apply per session; late-joiner pricing doesn't). A session can be booked until it starts (staff can book until it ends). Whole-course tickets can still be sold alongside, and stop selling when the course starts.
- **Session capacity**: in pick-sessions events each session can have a capacity, covering picked places plus whole-course tickets. Full sessions can't be ticked. A per-session ticket's stock limit is places per session.
- Picked-session tickets only scan on their own session ("This ticket is for Class 2 (2026-11-11)…"), on and offline. The attendance register shows one row per person with a dash for sessions they didn't book, and completion and certificates count the sessions they booked. Session reminders go only to the people booked on that session. Tickets, printouts and the ticket email show the session's date.
- The session picker is on the public registration page, Manual registration and the self-service kiosk.
- The roadmap to 2.0 and a handover note are now in the repo: `docs/ROADMAP-v2.md`, `docs/HANDOVER.md` (plus the previous round's spec, `docs/FEATURES-ROUND-1.md`).
### Changed
- Per-session tickets can't be changed from My Events' "edit booking" (book more sessions by registering again); they still count towards the total. At the door, per-session tickets are booked from Manual registration (the page says so).
## [1.34.0] - 2026-10-22
### Added
- **Background jobs page** (Admin → Site Settings → Background jobs): every scheduled job with its schedule, next run and last 10 runs (start, finish, result or error), and a "Run now" button (recorded in the audit log).
- **End-to-end tests** with Playwright (`npm run test:e2e`): they start their own backend and frontend against a separate, freshly seeded database and cover the public pages, free and paid registration through payment, ticket, check-in and cashup, multi-day check-in with the attendance register and certificates, creating a multi-session event in the wizard, the self-service kiosk, and the jobs page.
- **CI workflow** (`.gitea/workflows/ci.yml`): backend tests, frontend lint, typecheck and build, and the end-to-end tests on every push and pull request. It runs once a Gitea Actions runner is registered.
- **Deploy script** (`scripts/deploy.sh <tag>`): backs up the database, checks out the tag, installs, migrates, builds and reloads PM2, going back to the previous version if a step fails before the reload. CI runs it over SSH for `v*` tags once the `DEPLOY_*` secrets are set.
### Changed
- Timed background jobs (daily summary, waitlist, feedback, session reminders and certificates, consent, analytics rollup, backup, temp cleanup) now run on a job queue in Postgres (pg-boss) instead of in-process timers. Each runs once per schedule even with more than one server process, failed runs are retried, and a slow run never overlaps the next. Schedules are cron, in `APP_TIMEZONE` (Africa/Johannesburg by default) rather than the server's clock; change one with `JOB_SCHEDULE_<NAME>`, or turn all jobs off with `JOBS_ENABLED=false`. The existing `*_ENABLED` flags still work; the old `*_JOB_INTERVAL_MS` settings are replaced by the schedules. If the queue can't start, the jobs run on timers as before.
- The server lets a running job finish before it stops or restarts.
### Fixed
- At the door, the Check-In tab counted every past session's check-in on a multi-day ticket, so from the second session the ticket showed as fully used. It now counts only today's session.
## [1.33.0] - 2026-10-22
### Added
- **Multi-day events.** An event can run over several sessions, e.g. every Wednesday 18:30–20:30 for 5 weeks. In the event wizard choose "Multiple sessions", create the dates from a repeat pattern (daily or weekly, every N, on chosen weekdays, for a number of sessions or until a date), then edit any session's label, times or location, add one-off dates, or cancel a session (kept for history). People register and pay once and get one ticket for every session.
- The event page shows the schedule (past sessions greyed out, the next one highlighted, cancelled ones struck through). Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec", and multi-day events stay listed until their last session, sorted by their next one. Calendar files have one entry per session. Ticket PDFs, printed tickets and the ticket email give the session range.
- **Late joining**: a multi-day event closes registration when its first session starts, unless late joining is on (until session N, set per event). Late joiners pay full price, a pro-rata price for the sessions still to come (rounded to the nearest R10, at least R10), or a fixed late price — main tickets only, and never more than the normal price. The event and registration pages say when the late price applies.
- **Per-session check-in**: a multi-day ticket is checked in once per session. The scanner uses today's session (staff can pick another), shows the session on the confirm screen, and says "Already checked in for Week 3 at 18:42" on a second scan. Each event chooses whether check-in is open the whole day of a session (the default) or only from 2 hours before it until it ends. Offline mode downloads the list for today's session, and synced scans count for the session at their scan time.
- **Session reminders**: optional per event — 1 hour, 3 hours and/or 1 day before each session, by email and/or WhatsApp (each person's notification preference still applies). Off by default.
- **Attendance register** (Dashboard → Attendance): attendees × sessions with ticks, totals per session, completion, search and filters (completed, not completed, missed the last two sessions), and Excel/PDF export. Supervisors can tick someone present or absent, recorded in the audit log. Also works for single-date events (one "Checked in" column).
- **Completion certificates**: optional per event, in the wizard's new Attendance step. Four designs (Classic, Modern, Church, Kids) with the site logo and colour, your title and wording ({name}, {event}, {sessionsAttended}, {totalSessions}, {endDate}), an optional signature name, role and image, and who qualifies (every session, at least N sessions, or at least N%). "Preview certificate" shows a sample. Certificates are issued automatically when the event ends (and can be sent automatically), or from the attendance register: issue now, issue to one person anyway, send, download all as one PDF, or revoke. People download theirs under My Certificates on My Events. Each has a code and QR code that anyone can check on the public page `/certificates/verify/<code>`, which shows only the name, event and dates.
- Saving an event where sessions moved or were cancelled offers a "Message attendees" link, since registrants aren't told automatically.
- A background job every 5 minutes sends due session reminders and issues certificates (`SESSION_JOBS_ENABLED=false` to turn it off). The church's timezone for "today" defaults to Africa/Johannesburg (`APP_TIMEZONE` to change).
### Changed
- The event wizard has a ninth step, Attendance, for late joining, check-in window, session reminders and certificates.
## [1.32.0] - 2026-10-22
### Added
- Event cards on the home page and events list show a diagonal "Early bird" ribbon in the top-right corner while the event has an early-bird tier running (deadline not passed and places left on that tier). It isn't shown when the event is sold out, registration is closed or not open yet, or the event is contact-only.
## [1.31.1] - 2026-10-22
### Added
- Settings → Organisation has a "Google Search Console verification" field. Paste the HTML tag from Search Console and the site outputs it on every page, so each deployment can verify its own domain without uploading a file.
## [1.31.0] - 2026-10-22
### Added
- The registration funnel's traffic source works without tagged links: visits from Facebook, Instagram, Google, X, LinkedIn and other sites are recognised from where the visitor came from (and Facebook's/Google's click ids). Links without a tag used to count as "direct". WhatsApp still only shows from tagged links (`?utm_source=whatsapp`), since it doesn't pass that on.
- The event page's Share button shares the link tagged `?utm_source=share`, and Save QR encodes it tagged `?utm_source=qr`, so those visits show separately.
- Event pages tell search engines about the event in Google's event format (schema.org `Event`): name, dates, venue, picture, organiser, ticket prices in Rand and whether it's sold out. Google can then show events with their date, venue and ticket link in search results and in its event listings. Contact-only events are listed without tickets.
### Changed
- Event pages, the events list and the home page tell search engines their one official address, so `?utm_source=…`, `?src=…` and alias links all count towards the same page in search rankings. Shared event links also carry the event's address.
## [1.30.1] - 2026-10-22
### Changed
+71 -14
View File
@@ -56,7 +56,9 @@ Four roles, each including everything the roles below it can do:
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
logged in).
- **Events list** (`/events`) — event cards with picture, dates, location,
price, and a Register / Sold Out / Contact us button.
price, and a Register / Sold Out / Contact us button. A diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open.
- **Event detail** (`/events/[id]`) — description, ticket options with
variant and early-bird pricing, low-stock and sold-out badges, downloadable
attachments, embedded Google Map with "Directions" link, and an
@@ -77,14 +79,41 @@ Four roles, each including everything the roles below it can do:
## Events
Created and edited by supervisors through a 6-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Attachments**.
Created and edited by supervisors through a 9-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Notifications → Attachments →
Feedback → Attendance**.
- **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address),
contact person.
- **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets.
- **Multi-day events (sessions)** — one event on several dates, e.g. every
Wednesday 18:30–20:30 for 5 weeks. A repeat pattern (daily/weekly, every N,
chosen weekdays, a number of sessions or an end date) creates the sessions,
which can then be edited one by one (label, times, location), added to, or
cancelled (kept for history; one with check-ins can't be deleted). People
register and pay once and get one ticket. The event page shows the schedule
with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed
2 Dec" and stay listed until the last session. Calendar files get one entry
per session. Saving moved or cancelled sessions offers to message attendees.
- **Pick sessions** (multi-day) — instead of the whole course, people can choose
which sessions to come to (day passes, drop-in classes). Tickets set to "Pick
sessions" are priced and bought per session — one line and one ticket per session,
bookable until the session starts (staff: until it ends) — alongside optional
whole-course tickets. Sessions can have a capacity (picked places plus whole-course
tickets); a per-session ticket's stock limit is places per session. Picked tickets
only scan on their own session; reminders, the register and certificates follow
the sessions each person booked. Not available on youth events.
- **Late joining** (multi-day) — registration closes when the first session
starts, or later (until session N) with late joining on. Late joiners pay
full price, a pro-rata price for the sessions left (rounded to the nearest
R10), or a fixed late price — main tickets only.
- **Completion certificates** — optional per event: four designs (Classic,
Modern, Church, Kids), custom wording with placeholders, signature name/role/
image, and a completion rule (every session, at least N sessions, or N%).
Issued automatically when the event ends; each has a code and QR code checked
on a public verify page.
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
T-shirt), each with a price and optional **stock limit**. One option is marked
as the main ticket, which is what gets checked in at the door.
@@ -240,6 +269,15 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
and remaining quantity.
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
checked in and how many remain.
- **Per-session check-in** — multi-day tickets are checked in once per session.
The scanner uses today's session (or one picked from a list). Check-in is open
the whole day of a session, or only from 2 hours before it until it ends, per
event. Optional reminders (1 h / 3 h / 1 day before each session) by email
and/or WhatsApp.
- **Attendance register** — attendees × sessions with ticks, totals per session,
completion, filters (completed, missed the last two), Excel/PDF export.
Supervisors can tick someone present or absent (audit-logged), and issue,
send, download or revoke certificates.
- **Scan stats** — recent scans and per-event scan counts.
- **Offline scanning** — while online, a staff member locks an event section (or the
whole event) to their device and downloads its tickets. They can then keep scanning
@@ -344,7 +382,7 @@ logout controls. Disconnected sessions are recovered automatically.
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Manage events** — the 6-step event wizard, ticket options, variants,
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
payments, a **Check-In** tab for redeeming main tickets by quantity, and
@@ -499,16 +537,25 @@ falling back to a general guide.
## Background jobs
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
runs once per schedule even with several server processes, every run is recorded,
failed runs are retried twice, and a slow run never overlaps the next. Admin → Site
Settings → Background jobs shows each job's schedule, next run and last 10 runs, with
"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule
with `JOB_SCHEDULE_<NAME>` (cron); `JOBS_ENABLED=false` turns them all off. If the queue
can't start, the same jobs run on in-process timers.
| Job | Schedule |
|-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
---
@@ -520,7 +567,16 @@ falling back to a general guide.
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Jest tests for early-bird tranche pricing and donation balances.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
registration through to check-in and cashup, multi-day check-in, the attendance
register and certificates, the event wizard, the kiosk, and the admin jobs page.
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
goes back to the previous version if a step fails before the reload.
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
---
@@ -541,9 +597,10 @@ falling back to a general guide.
| `/setup` | First run | Setup wizard |
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/certificates/verify/[code]` | Public | Check a completion certificate |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+18
View File
@@ -81,8 +81,26 @@ Documentation: see Frontend README for environment and scripts.
- npm run dev
- Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port.
## Tests
- Backend unit tests: `cd backend && npm test`
- End-to-end tests (Playwright), from the repo root:
- once: `npm install` and `npx playwright install chromium`
- `npm run test:e2e`
- They start their own backend (port 5100) and frontend (port 3100) against a separate
database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
servers and database aren't touched. The database user needs permission to create
databases.
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
## Deployment
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
(backs up the database, migrates, builds, reloads PM2).
### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL.
+78 -2
View File
@@ -21,6 +21,7 @@ Tokens are issued by `POST /api/users/login`.
- [Coupons](#coupons)
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Multi-day events & attendance](#multi-day-events--attendance)
- [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer)
- [Rooms & groups](#rooms--groups)
@@ -28,6 +29,7 @@ Tokens are issued by `POST /api/users/login`.
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -285,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Background jobs
Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) —
see `backend/src/jobs/index.js`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` |
| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` |
Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …)
turns it off; `JOB_SCHEDULE_<NAME>` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its
cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg).
---
## Impersonation
| Method | Path | Auth | Description |
@@ -330,7 +348,7 @@ Data handling:
Notes:
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
- Only HMACs of link tokens are stored, and a reminder issues a new link.
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
## Sponsor a spot
@@ -422,6 +440,64 @@ How syncing works:
- Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
## Multi-day events & attendance
An event with `scheduleType: "sessions"` runs on several dates (e.g. every Wednesday for
5 weeks). People register and pay once and get one ticket; check-in is per session.
`startDate`/`endDate` always follow the first and last active session.
Create/update fields (`POST`/`PUT /api/events`):
| Field | Notes |
|-------|-------|
| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins |
| `attendanceMode` | `whole` (default) or `pick` — people choose sessions. Not allowed with `requiresParentalConsent` |
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled?, capacity? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only |
| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) |
| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` |
| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` |
Event options take `sessionMode: "all" | "pick"` (`POST /api/events/:id/options`, `PUT /api/events/options/:id`).
In a pick-mode event, a `pick` option is bought per session: registration lines send
`sessionIds: [...]` (`POST /api/registrations`, `/manual`, coupon/bundle quotes), and each
session becomes its own registration line (`RegistrationOption.sessionId`) and ticket.
Capacity counts picked places plus whole-course main tickets; a pick option's
`stockLimit` is per session. `PUT /api/registrations/:id/options` refuses pick options and
leaves picked lines untouched. Pick-mode events stay open until the last session starts
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`),
`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`.
`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`.
Scanning: `GET /api/tickets/scan-preview/:qr?sessionId=` and `POST /api/tickets/scan/:qr`
(`{ sessionId? }`) default to today's session and return `session` plus `remaining` for it;
`sessionError` / 400 `NO_SESSION` when no session is open for check-in. Offline snapshots
are for the session running when downloaded; synced scans count for the session at their scan time.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/attendance/:eventId` | staff+ | Register: `columns` (sessions), `rows` (one per main ticket: `checkIns` per session, `attended`, `total`, `completed`, `certificate`), `totals` |
| GET | `/api/attendance/:eventId/export?format=xlsx\|pdf` | staff+ | Download the register |
| POST | `/api/attendance/:eventId/mark` | supervisor+ | `{ ticketId, sessionId, present }` — single-date events use `sessionId: "event"` |
| POST | `/api/attendance/:eventId/certificates` | supervisor+ | Issue now to everyone eligible, or `{ ticketId }` for one (even if not eligible) |
| POST | `/api/attendance/:eventId/certificates/send` | supervisor+ | Send unsent certificates, or `{ certificateId }` |
| GET | `/api/attendance/:eventId/certificates.pdf` | supervisor+ | All certificates in one PDF |
| POST | `/api/attendance/certificates/preview` | supervisor+ | Sample PDF: `{ title, config }` |
| POST | `/api/attendance/certificates/:id/revoke` | supervisor+ | Revoke (never re-issued automatically) |
| GET | `/api/attendance/certificates/mine` | user+ | My certificates |
| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) |
| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` |
A job every 5 minutes (`sessions` job; `SESSION_JOBS_ENABLED`, `JOB_SCHEDULE_SESSIONS`) sends due session
reminders (each once; when several are due at once only the closest is sent) and issues
certificates for events with certificates on that ended in the last 14 days.
---
## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
@@ -440,7 +516,7 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
+67 -50
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.30.1",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.30.1",
"version": "1.35.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
@@ -15,6 +15,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -25,6 +26,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -1615,9 +1617,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1639,9 +1638,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1663,9 +1659,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1687,9 +1680,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1711,9 +1701,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,9 +1722,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2707,9 +2691,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2724,9 +2705,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2741,9 +2719,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2758,9 +2733,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2775,9 +2747,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2792,9 +2761,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2809,9 +2775,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2826,9 +2789,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2843,9 +2803,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2860,9 +2817,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4143,6 +4097,19 @@
"node": ">= 10"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7134,6 +7101,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7761,6 +7737,20 @@
}
}
},
"node_modules/pg-boss": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"pg": "^8.16.3",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
@@ -8526,6 +8516,33 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serialize-error/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.30.1",
"version": "1.35.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -23,6 +23,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -33,6 +34,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -0,0 +1,132 @@
-- CreateEnum
CREATE TYPE "ScheduleType" AS ENUM ('single', 'sessions');
-- CreateEnum
CREATE TYPE "AttendanceMode" AS ENUM ('whole', 'pick');
-- CreateEnum
CREATE TYPE "LatePricing" AS ENUM ('full', 'prorata', 'fixed');
-- CreateEnum
CREATE TYPE "CheckInWindow" AS ENUM ('whole_day', 'session_only');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'event_sessions_changed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_marked';
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_unmarked';
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_issued';
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_revoked';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "attendanceMode" "AttendanceMode" NOT NULL DEFAULT 'whole',
ADD COLUMN "certificateConfig" JSONB,
ADD COLUMN "certificatesEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "checkInWindow" "CheckInWindow" NOT NULL DEFAULT 'whole_day',
ADD COLUMN "lateFixedPrice" DOUBLE PRECISION,
ADD COLUMN "lateJoinEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lateJoinUntilSessionId" TEXT,
ADD COLUMN "latePricing" "LatePricing" NOT NULL DEFAULT 'full',
ADD COLUMN "scheduleType" "ScheduleType" NOT NULL DEFAULT 'single',
ADD COLUMN "sessionReminders" JSONB;
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sessionId" TEXT;
-- CreateTable
CREATE TABLE "EventSession" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"label" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"location" TEXT,
"cancelledAt" TIMESTAMP(3),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SessionReminderLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"offset" INTEGER NOT NULL,
"sent" INTEGER NOT NULL DEFAULT 0,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SessionReminderLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Certificate" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"userId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sessionsAttended" INTEGER NOT NULL DEFAULT 0,
"totalSessions" INTEGER NOT NULL DEFAULT 0,
"manual" BOOLEAN NOT NULL DEFAULT false,
"issuedById" TEXT,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"emailedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventSession_eventId_startsAt_idx" ON "EventSession"("eventId", "startsAt");
-- CreateIndex
CREATE UNIQUE INDEX "SessionReminderLog_sessionId_offset_key" ON "SessionReminderLog"("sessionId", "offset");
-- CreateIndex
CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
-- CreateIndex
CREATE INDEX "Certificate_userId_idx" ON "Certificate"("userId");
-- CreateIndex
CREATE INDEX "Certificate_ticketId_idx" ON "Certificate"("ticketId");
-- CreateIndex
CREATE INDEX "Certificate_issuedById_idx" ON "Certificate"("issuedById");
-- CreateIndex
CREATE UNIQUE INDEX "Certificate_eventId_ticketId_key" ON "Certificate"("eventId", "ticketId");
-- CreateIndex
CREATE INDEX "TicketUsage_sessionId_idx" ON "TicketUsage"("sessionId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventSession" ADD CONSTRAINT "EventSession_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionReminderLog" ADD CONSTRAINT "SessionReminderLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
-- AlterTable
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
-- AddForeignKey
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'job_run_manual';
+135
View File
@@ -119,6 +119,8 @@ model User {
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
certificates Certificate[]
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
}
model Event {
@@ -199,6 +201,28 @@ model Event {
scanLocks ScanLock[]
bundles EventBundle[]
// Multi-day events: `sessions` is one event running on several dates (e.g. every
// Wednesday for 5 weeks). startDate/endDate are kept in sync with the first session's
// start and the last session's end (see utils/sessions.js), so code that only reads
// them keeps working.
scheduleType ScheduleType @default(single)
attendanceMode AttendanceMode @default(whole)
// Late joining: registration stays open until session `lateJoinUntilSessionId` starts
// (otherwise it closes when the first session starts). Late pricing applies to main
// tickets only.
lateJoinEnabled Boolean @default(false)
lateJoinUntilSessionId String?
latePricing LatePricing @default(full)
lateFixedPrice Float?
checkInWindow CheckInWindow @default(whole_day)
// null = off; { offsets: [minutes before start], channels: ["email", "whatsapp"] }
sessionReminders Json?
certificatesEnabled Boolean @default(false)
// { template, title, body, signatureName, signatureRole, signatureImage, rule: { type, value } }
certificateConfig Json?
sessions EventSession[]
certificates Certificate[]
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@ -213,6 +237,9 @@ model EventOption {
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
// (price is per session, stockLimit is places per session); `all` options cover every session.
sessionMode OptionSessionMode @default(all)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
@@ -313,11 +340,16 @@ model RegistrationOption {
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
// A "pick sessions" option: one line (and ticket) per session booked. Null for
// everything else.
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([registrationId])
@@index([eventOptionId])
@@index([variantId])
@@index([appliedTierId])
@@index([sessionId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
@@ -398,6 +430,7 @@ model Ticket {
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
certificates Certificate[]
@@index([holderUserId])
@@index([eventId])
@@ -418,6 +451,11 @@ model TicketUsage {
offline Boolean @default(false)
scanLockId String?
syncedAt DateTime?
// Multi-day events: the session this check-in counts for (null on single-date events).
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
// Ticked present by a supervisor from the attendance register, not scanned.
manual Boolean @default(false)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
@@ -425,6 +463,7 @@ model TicketUsage {
@@index([ticketId])
@@index([scannedAt])
@@index([scannedById, scannedAt])
@@index([sessionId])
}
model PasswordReset {
@@ -518,6 +557,12 @@ enum AdminAuditAction {
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
event_sessions_changed
attendance_marked
attendance_unmarked
certificate_issued
certificate_revoked
job_run_manual
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -1391,3 +1436,93 @@ model OfflineSyncReport {
@@index([scanLockId])
@@index([reviewedById])
}
enum ScheduleType {
single
sessions
}
enum AttendanceMode {
whole
pick
}
enum LatePricing {
full
prorata
fixed
}
enum OptionSessionMode {
all
pick
}
enum CheckInWindow {
whole_day
session_only
}
// One date/time of a multi-day event.
model EventSession {
id String @id @default(uuid())
eventId String
label String?
startsAt DateTime
endsAt DateTime
location String?
// Cancelled sessions are kept (they may have check-ins) but hidden from the schedule
// and left out of attendance totals.
cancelledAt DateTime?
order Int @default(0)
// Optional limit on people at this session (picked places + whole-course tickets).
capacity Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
@@index([eventId, startsAt])
}
// Which session reminders have gone out, so the job sends each one once.
model SessionReminderLog {
id String @id @default(uuid())
sessionId String
// Minutes before the session start this reminder is for.
offset Int
sent Int @default(0)
sentAt DateTime @default(now())
session EventSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@unique([sessionId, offset])
}
// A completion certificate for one ticket (one person).
model Certificate {
id String @id @default(uuid())
eventId String
ticketId String
userId String?
name String
// Short code printed on the certificate and checked on the public verify page.
code String @unique
sessionsAttended Int @default(0)
totalSessions Int @default(0)
manual Boolean @default(false)
issuedById String?
issuedAt DateTime @default(now())
emailedAt DateTime?
revokedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
issuedBy User? @relation("CertificateIssuedBy", fields: [issuedById], references: [id], onDelete: SetNull)
@@unique([eventId, ticketId])
@@index([userId])
@@index([ticketId])
@@index([issuedById])
}
@@ -0,0 +1,408 @@
const ExcelJS = require('exceljs');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const certificates = require('../utils/certificates');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const notFound = (msg) => Object.assign(new Error(msg), { statusCode: 404 });
const badRequest = (msg) => Object.assign(new Error(msg), { statusCode: 400 });
// Single-date events have no sessions; the register treats the event itself as one
// column with this id (its check-ins have no sessionId).
const WHOLE_EVENT = 'event';
/**
* The attendance register for an event: columns (sessions), one row per main ticket with
* check-ins per session, and certificate status.
*/
async function buildRegister(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event) throw notFound('Event not found');
const multi = event.scheduleType === 'sessions';
const active = sessionsUtil.activeSessions(event.sessions);
const columns = multi
? event.sessions.map(s => ({
id: s.id, label: s.cancelledAt ? (s.label || 'Session') : sessionsUtil.sessionLabel(s, event.sessions),
startsAt: s.startsAt, endsAt: s.endsAt, cancelled: !!s.cancelledAt,
}))
: [{ id: WHOLE_EVENT, label: 'Checked in', startsAt: event.startDate, endsAt: event.endDate, cancelled: false }];
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
include: {
attendee: { select: { name: true } },
holder: { select: { name: true } },
user: { select: { name: true, email: true, phoneNumber: true } },
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
},
orderBy: { createdAt: 'asc' },
});
const activeIds = new Set(active.map(s => s.id));
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
// A picked-session booking (one ticket per session) shows as one row.
const rows = sessionsUtil.groupTickets(tickets).map(g => {
const t = g.tickets[0];
const bySession = {};
for (const tk of g.tickets) {
for (const u of tk.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
}
}
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
const counted = booked ? new Set(booked) : activeIds;
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? counted.size : 1;
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked sessions: which sessions this person booked, and the ticket for each.
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
email: t.user?.email || '',
phone: t.user?.phoneNumber || '',
option: t.registrationOption?.eventOption?.name || '',
variant: t.registrationOption?.variant?.name || null,
quantity: t.quantity || 1,
checkIns: bySession,
attended,
total,
completed: sessionsUtil.completionMet(config.rule, attended, total),
certificate: cert,
};
}).sort((a, b) => a.name.localeCompare(b.name));
const totals = Object.fromEntries(columns.map(c => [c.id, rows.reduce((n, r) => n + (r.checkIns[c.id] ? 1 : 0), 0)]));
return {
event: {
id: event.id, title: event.title, scheduleType: event.scheduleType, startDate: event.startDate, endDate: event.endDate,
cashupStatus: event.cashupStatus, certificatesEnabled: event.certificatesEnabled, certificateConfig: config,
},
columns,
rows,
totals,
};
}
// @desc Attendance register (sessions × attendees)
// @route GET /api/attendance/:eventId
// @access Private/Staff
const getRegister = async (req, res) => {
try {
res.json(await buildRegister(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @desc Tick someone present or absent for a session (e.g. let in without a scan)
// @route POST /api/attendance/:eventId/mark body: { ticketId, sessionId, present }
// @access Private/Supervisor
const markAttendance = async (req, res) => {
try {
const { eventId } = req.params;
const { ticketId, present } = req.body || {};
const sessionKey = String(req.body?.sessionId || '');
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true } } } });
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
const multi = ticket.event.scheduleType === 'sessions';
let sessionId = null;
if (multi) {
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
if (session.cancelledAt) throw badRequest('That session was cancelled');
if (ticket.registrationOption?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
sessionId = session.id;
} else if (sessionKey !== WHOLE_EVENT) {
throw badRequest('This event has no sessions');
}
const forSession = ticket.usages.filter(u => (u.sessionId || null) === sessionId);
if (present === true) {
const redeemed = forSession.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const left = (ticket.quantity || 1) - redeemed;
if (left > 0) {
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: req.user.id, quantityRedeemed: left, sessionId, manual: true } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
}
} else {
if (forSession.length > 0) {
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map(u => u.id) } } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
}
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: present === true ? 'attendance_marked' : 'attendance_unmarked',
targetType: 'Ticket', targetId: ticket.id,
metadata: { eventId, sessionId, removedScans: present === true ? 0 : forSession.length },
ip: getClientIp(req),
});
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
};
function registerFileName(reg, ext) {
return `Attendance - ${reg.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + `.${ext}`;
}
function fmtColumnDate(d) {
return new Date(d).toLocaleDateString('en-GB', { timeZone: sessionsUtil.TIMEZONE, day: 'numeric', month: 'short' });
}
// @desc Download the attendance register as Excel or PDF
// @route GET /api/attendance/:eventId/export?format=xlsx|pdf
// @access Private/Staff
const exportRegister = async (req, res) => {
try {
const reg = await buildRegister(req.params.eventId);
const cols = reg.columns.filter(c => !c.cancelled);
if (req.query.format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'pdf')}"`);
doc.pipe(res);
doc.font('Helvetica-Bold').fontSize(16).text(`Attendance — ${reg.event.title}`);
doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(`${reg.rows.length} attendee(s) · printed ${new Date().toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE })}`);
doc.moveDown(0.6);
const nameW = 170;
const totalW = 50;
const usable = doc.page.width - 72 - nameW - totalW;
const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length)));
// Header height fits the longest session label.
const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`;
doc.font('Helvetica-Bold').fontSize(8);
const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 })));
const header = () => {
const y = doc.y;
doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW });
cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' }));
doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' });
doc.y = y + headH + 6;
doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke();
doc.y += 2;
};
// The built-in PDF fonts have no ✓, so the tick is drawn.
const tick = (cx, cy) => {
doc.save().lineWidth(1.4).strokeColor('#15803d')
.moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore();
};
header();
for (const r of reg.rows) {
if (doc.y > doc.page.height - 50) { doc.addPage(); header(); }
const y = doc.y;
doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false });
cols.forEach((c, i) => {
const qty = r.checkIns[c.id] || 0;
const x = 36 + nameW + i * colW;
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
});
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
doc.y = y + 14;
}
doc.moveDown(0.5);
const y = doc.y;
doc.font('Helvetica-Bold').fontSize(8.5).text('Present', 36, y, { width: nameW });
cols.forEach((c, i) => doc.text(String(reg.totals[c.id] || 0), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false }));
doc.end();
return;
}
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Attendance');
ws.addRow(['Name', 'Booked by', 'Ticket', 'Email', 'Phone', 'Places', ...cols.map(c => `${c.label} (${fmtColumnDate(c.startsAt)})`), 'Attended', 'Completed', 'Certificate']);
ws.getRow(1).font = { bold: true };
for (const r of reg.rows) {
ws.addRow([
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
]);
}
ws.addRow([]);
ws.addRow(['Present', '', '', '', '', '', ...cols.map(c => reg.totals[c.id] || 0)]).font = { bold: true };
ws.columns.forEach((col, i) => { col.width = i < 5 ? 22 : 14; });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'xlsx')}"`);
await wb.xlsx.write(res);
res.end();
} catch (error) {
fail(res, error);
}
};
// ─── Certificates (supervisor) ────────────────────────────────────────────────
async function assertCertificatesOn(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true, endDate: true, certificatesEnabled: true, certificateConfig: true } });
if (!event) throw notFound('Event not found');
if (!event.certificatesEnabled) throw badRequest('Certificates are switched off for this event (Edit event → Attendance).');
return event;
}
// @desc Issue certificates now — to everyone eligible, or one ticket (even if not eligible)
// @route POST /api/attendance/:eventId/certificates body: { ticketId? }
// @access Private/Supervisor
const issueCertificates = async (req, res) => {
try {
await assertCertificatesOn(req.params.eventId);
const created = req.body?.ticketId
? [await certificates.issueManual(req.params.eventId, String(req.body.ticketId), req.user.id)]
: await certificates.issueEligible(req.params.eventId, { issuedById: req.user.id });
for (const c of created) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_issued', targetType: 'Certificate', targetId: c.id, metadata: { eventId: req.params.eventId, ticketId: c.ticketId, manual: !!req.body?.ticketId }, ip: getClientIp(req) });
}
res.json({ issued: created.length });
} catch (error) {
fail(res, error);
}
};
// @desc Revoke a certificate (the automatic job won't issue it again)
// @route POST /api/attendance/certificates/:id/revoke
// @access Private/Supervisor
const revokeCertificate = async (req, res) => {
try {
const cert = await prisma.certificate.findUnique({ where: { id: req.params.id } });
if (!cert) throw notFound('Certificate not found');
await prisma.certificate.update({ where: { id: cert.id }, data: { revokedAt: new Date() } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_revoked', targetType: 'Certificate', targetId: cert.id, metadata: { eventId: cert.eventId, ticketId: cert.ticketId }, ip: getClientIp(req) });
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
};
// @desc Email/WhatsApp certificates that haven't been sent yet (or one, again)
// @route POST /api/attendance/:eventId/certificates/send body: { certificateId? }
// @access Private/Supervisor
const sendCertificatesHandler = async (req, res) => {
try {
await assertCertificatesOn(req.params.eventId);
const where = req.body?.certificateId
? { id: String(req.body.certificateId), eventId: req.params.eventId }
: { eventId: req.params.eventId, emailedAt: null };
const ids = (await prisma.certificate.findMany({ where: { ...where, revokedAt: null }, select: { id: true } })).map(c => c.id);
const sent = await certificates.sendCertificates(ids);
res.json({ sent, skipped: ids.length - sent });
} catch (error) {
fail(res, error);
}
};
// @desc All of an event's certificates in one PDF (one page each)
// @route GET /api/attendance/:eventId/certificates.pdf
// @access Private/Supervisor
const downloadEventCertificates = async (req, res) => {
try {
const event = await assertCertificatesOn(req.params.eventId);
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } });
if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
const pdf = await certificates.renderCertificatesPdf(certs, event);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// @desc Sample certificate with the editor's (unsaved) settings
// @route POST /api/attendance/certificates/preview body: { title, config }
// @access Private/Supervisor
const previewCertificate = async (req, res) => {
try {
const event = { title: String(req.body?.title || 'Your event').slice(0, 200), endDate: new Date() };
const pdf = await certificates.renderCertificatesPdf([certificates.sampleCertificate()], event, req.body?.config || {});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="certificate-preview.pdf"');
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// ─── Certificates (attendees, public) ─────────────────────────────────────────
// @desc The logged-in user's certificates
// @route GET /api/attendance/certificates/mine
// @access Private
const myCertificates = async (req, res) => {
try {
const certs = await prisma.certificate.findMany({
where: { revokedAt: null, OR: [{ userId: req.user.id }, { ticket: { userId: req.user.id } }] },
include: { event: { select: { id: true, title: true, endDate: true } } },
orderBy: { issuedAt: 'desc' },
});
res.json(certs.map(c => ({ id: c.id, code: c.code, name: c.name, issuedAt: c.issuedAt, sessionsAttended: c.sessionsAttended, totalSessions: c.totalSessions, event: c.event })));
} catch (error) {
fail(res, error);
}
};
// @desc Download one certificate (its person, the booker, or a supervisor)
// @route GET /api/attendance/certificates/:id/pdf
// @access Private
const downloadCertificate = async (req, res) => {
try {
const cert = await prisma.certificate.findUnique({
where: { id: req.params.id },
include: { ticket: { select: { userId: true } }, event: { select: { id: true, title: true, endDate: true, certificateConfig: true } } },
});
const allowed = cert && (SUPERVISORS.includes(req.user.role) || cert.userId === req.user.id || cert.ticket?.userId === req.user.id);
if (!allowed || cert.revokedAt) throw notFound('Certificate not found');
const pdf = await certificates.renderCertificatesPdf([cert], cert.event);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${`Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// @desc Check a certificate code (public verify page). Shows only name, event and date.
// @route GET /api/attendance/certificates/verify/:code
// @access Public
const verifyCertificate = async (req, res) => {
try {
const code = String(req.params.code || '').trim().toUpperCase().slice(0, 20);
const cert = await prisma.certificate.findUnique({ where: { code }, include: { event: { select: { title: true, endDate: true } } } });
if (!cert) return res.json({ valid: false });
res.json({
valid: !cert.revokedAt,
revoked: !!cert.revokedAt,
name: cert.name,
event: cert.event.title,
completedOn: cert.event.endDate,
issuedAt: cert.issuedAt,
});
} catch (error) {
fail(res, error);
}
};
module.exports = {
getRegister, markAttendance, exportRegister,
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
myCertificates, downloadCertificate, verifyCertificate,
};
+3 -1
View File
@@ -250,7 +250,9 @@ async function basketBuyer(req, forUserId) {
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
for (const o of options || []) {
// A "pick sessions" line counts once per chosen session.
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
+190 -17
View File
@@ -7,6 +7,85 @@ const crypto = require('crypto');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
// flagged), when registration closes, the next session, and late-joiner pricing.
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
function withSchedule(ev, bookings = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
// The schedule/attendance fields of an event from a create/update body. `current` is the
// saved event on update (unsent fields keep their value).
function scheduleData(body, current = {}) {
const has = (k) => body[k] !== undefined;
const data = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session after an event is created
* or updated, inside the caller's transaction (`tx`) so a refused change saves nothing.
* `lateJoinUntilSessionIndex` points into the sent session list (sorted by start).
* @returns {Promise<object|null>} the change counts, or null when nothing was sent
*/
async function saveSchedule(tx, eventId, body) {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
@@ -81,7 +160,14 @@ const createEvent = async (req, res) => {
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const logEventCreate = (createdEvent) => logAdminAction({
actorId: req.user?.id,
@@ -96,6 +182,7 @@ const createEvent = async (req, res) => {
try {
const event = await prisma.event.create({ data });
logEventCreate(event);
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body));
// Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
@@ -151,7 +238,7 @@ const createEvent = async (req, res) => {
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
return res.status(201).json(event);
return res.status(201).json(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } })));
} catch (err) {
// Gracefully handle environments where migration isn't applied yet
const msg = String(err?.message || '');
@@ -186,10 +273,49 @@ const createEvent = async (req, res) => {
throw err;
}
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Map of eventId → when its early bird ends, for events with at least one early-bird
// tier still running: deadline not passed and (no stock limit, or places left, counted
// across tranches the same way as utils/pricing.js). The date is the latest deadline
// among the running tiers.
async function earlyBirdEndsByEvent(events) {
const now = new Date();
const running = [];
for (const ev of events) {
for (const opt of ev.eventOptions || []) {
for (const tier of opt.earlyBirdTiers || []) {
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
}
}
}
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
const sold = new Map();
if (limitedIds.length > 0) {
const totals = await prisma.registrationOptionTranche.groupBy({
by: ['appliedTierId'],
where: {
appliedTierId: { in: limitedIds },
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
}
const ends = new Map();
for (const { eventId, tier } of running) {
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
const deadline = new Date(tier.deadline);
const current = ends.get(eventId);
if (!current || deadline > current) ends.set(eventId, deadline);
}
return ends;
}
// @desc Get all events
// @route GET /api/events
// @access Public
@@ -207,7 +333,7 @@ const getEvents = async (req, res) => {
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() }
},
include: { eventOptions: eventOptionsInclude }
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
@@ -220,20 +346,24 @@ const getEvents = async (req, res) => {
});
}
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
// Compute isSoldOut per event: true when every option that has a stock limit
// is fully sold out. Events with no limited options are never sold out.
const withStock = await Promise.all(events.map(async ev => {
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
// Picked-session options have per-session limits, so they never make the event sold out.
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
let isSoldOut = false;
if (limitedOpts.length > 0) {
const { takenCount } = require('../utils/stock');
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt });
} catch {
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt });
}
}));
@@ -251,11 +381,12 @@ const getAllEvents = async (req, res) => {
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const events = await prisma.event.findMany({
include: {
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
sessions: SESSIONS_INCLUDE,
}
});
const mapped = events.map(ev => ({
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
@@ -292,12 +423,13 @@ const getEventsAll = async (req, res) => {
where,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } }
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
},
orderBy: { startDate: 'asc' }
});
const mapped = events.map(ev => ({
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
@@ -320,7 +452,7 @@ const getEventById = async (req, res) => {
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true };
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
@@ -431,6 +563,16 @@ const getEventById = async (req, res) => {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
// Picked-session options: the stock limit is places per session.
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
@@ -448,7 +590,9 @@ const getEventById = async (req, res) => {
// Non-fatal: fall back to options without stock info
}
const mapped = {
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const mapped = withSchedule({
...event,
eventOptions: eventOptionsWithStock,
picture: toAbsoluteUrl(req, event.picture),
@@ -458,7 +602,7 @@ const getEventById = async (req, res) => {
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
};
}, bookings);
res.json(mapped);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
@@ -473,7 +617,7 @@ const getEventIcs = async (req, res) => {
const eventId = req.params.id;
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event) {
@@ -583,10 +727,36 @@ const updateEvent = async (req, res) => {
location: location !== undefined ? (location || null) : event.location,
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
try {
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body;
let updatedEvent;
let sessionChanges;
await prisma.$transaction(async (tx) => {
updatedEvent = await tx.event.update({ where: { id: req.params.id }, data });
sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single'
? await saveSchedule(tx, updatedEvent.id, scheduleBody)
: null;
});
sessionsUtil.forgetSchedule(updatedEvent.id);
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req),
});
}
updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } }));
updatedEvent.sessionChanges = sessionChanges;
// Upsert form if provided
@@ -768,7 +938,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -779,8 +949,10 @@ const createEventOption = async (req, res) => {
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
}
});
sessionsUtil.forgetSchedule(req.params.id);
res.status(201).json(eventOption);
} catch (error) {
@@ -805,7 +977,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -815,6 +987,7 @@ const updateEventOption = async (req, res) => {
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
}
});
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const jobs = require('../jobs');
// @desc Background jobs: schedule, next run and the last 10 runs of each
// @route GET /api/admin/jobs
// @access Private/Admin
const listJobs = async (req, res) => {
try {
res.json(await jobs.jobStatus(prisma));
} catch (error) {
res.status(500).json({ message: safeErrorMessage(error) });
}
};
// @desc Run a background job now
// @route POST /api/admin/jobs/:name/run
// @access Private/Admin
const runJobNow = async (req, res) => {
try {
const result = await jobs.runNow(req.params.name);
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'job_run_manual',
targetType: 'Job', targetId: req.params.name, metadata: { queued: result.queued }, ip: getClientIp(req),
});
res.json(result);
} catch (error) {
res.status(error.statusCode || 500).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listJobs, runJobNow };
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
@@ -52,6 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -91,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
@@ -121,7 +123,7 @@ const createRegistration = async (req, res) => {
: true;
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: optionInclude ? { include: optionInclude } : true }
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
});
if (!event) {
@@ -207,11 +209,10 @@ const createRegistration = async (req, res) => {
}
}
// Enforce registration cutoff for public registrations
// Enforce registration cutoff for public registrations (multi-day events close when
// the first session starts, or later with late joining — see utils/sessions.js).
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
if ((deadline && now >= deadline) || now >= end) {
if (now >= registrationClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Registration is closed for this event');
}
@@ -222,11 +223,20 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = expandPickLines(event, options, { now });
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await assertSessionCapacity(event, lines);
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
const resolvedOptions = await Promise.all(options.map(async (option) => {
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
@@ -239,7 +249,7 @@ const createRegistration = async (req, res) => {
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -259,7 +269,7 @@ const createRegistration = async (req, res) => {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -320,9 +330,9 @@ const createRegistration = async (req, res) => {
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
await prisma.$transaction([
@@ -348,6 +358,7 @@ const createRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -395,6 +406,7 @@ const createRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -941,7 +953,7 @@ const createManualRegistration = async (req, res) => {
//Check event
const event = await prisma.event.findUnique({
where: {id: eventId},
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
});
if (!event) {
@@ -962,9 +974,13 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Picked sessions become one line each; staff can still book a session until it ends.
const lines = expandPickLines(event, options, { staff: true });
await assertSessionCapacity(event, lines);
// Validate each option up front, before any account is created or changed —
// including stock, which staff registrations previously skipped.
for (const option of options) {
for (const option of lines) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
res.status(400);
@@ -975,8 +991,8 @@ const createManualRegistration = async (req, res) => {
throw new Error('Quantity must be at least 1');
}
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
const oStock = await checkOptionStock(eventOption, option.quantity);
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
@@ -986,7 +1002,7 @@ const createManualRegistration = async (req, res) => {
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const priceManualOptions = (attendee) => Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
@@ -1158,7 +1174,7 @@ const createManualRegistration = async (req, res) => {
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
return prisma.$transaction([
@@ -1188,6 +1204,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1256,6 +1273,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1465,6 +1483,7 @@ const updateRegistrationOptions = async (req, res) => {
}
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
for (const opt of options) {
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
res.status(400);
@@ -1474,8 +1493,17 @@ const updateRegistrationOptions = async (req, res) => {
res.status(400);
throw new Error('One or more options do not belong to this event');
}
if (pickOptionIds.has(opt.eventOptionId)) {
res.status(400);
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
}
}
// Picked-session lines aren't edited here: leave them exactly as they are (they still
// count towards the total due).
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
// Compute totalPaid
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
@@ -1576,6 +1604,7 @@ const updateRegistrationOptions = async (req, res) => {
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
newTotalDue += rowsTotal(pickedLines);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
@@ -21,6 +21,8 @@ const PUBLIC_KEYS = [
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Google Search Console "HTML tag" code, output as a meta tag on every page
'google_site_verification',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
+73 -15
View File
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
@@ -102,7 +103,7 @@ const getUserTickets = async (req, res) => {
},
},
user: { select: { id: true, name: true } },
event: true,
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
@@ -262,12 +263,14 @@ const getScanPreview = async (req, res) => {
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
@@ -275,7 +278,8 @@ const getScanPreview = async (req, res) => {
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt"
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
@@ -303,12 +307,33 @@ const getScanPreview = async (req, res) => {
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
@@ -317,7 +342,7 @@ const getScanPreview = async (req, res) => {
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages: r.usages || []
usages
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -337,6 +362,8 @@ const scanTicket = async (req, res) => {
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -345,7 +372,8 @@ const scanTicket = async (req, res) => {
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt"
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
@@ -356,7 +384,7 @@ const scanTicket = async (req, res) => {
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
@@ -405,11 +433,35 @@ const scanTicket = async (req, res) => {
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
@@ -452,11 +504,13 @@ const scanTicket = async (req, res) => {
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: fullyUsed, updatedAt: new Date() }
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
@@ -468,8 +522,10 @@ const scanTicket = async (req, res) => {
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
@@ -491,10 +547,10 @@ const getTicketsByEvent = async (req, res) => {
const tickets = await prisma.ticket.findMany({
where: { eventId: req.params.eventId },
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -861,7 +917,8 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
// Event title, with the date on the right of the same line
let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '';
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false });
@@ -925,8 +982,8 @@ async function emailTicketsInternal(req, res, userOverride) {
}
const ticketInclude = {
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
@@ -1034,9 +1091,10 @@ async function emailTicketsInternal(req, res, userOverride) {
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title;
const eventDate = tickets[0].event.startDate
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null) || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: '';
: '');
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
const sentChannels = [];
+29 -194
View File
@@ -185,6 +185,7 @@ app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/allocations', require('./routes/allocationRoutes'));
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/attendance', require('./routes/attendanceRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
@@ -546,7 +547,7 @@ app.get('/docs', async (req, res) => {
responses:[
{ status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }},
]},
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event',
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event. Multi-day events send scheduleType:"sessions" with the full sessions list (rows left out are deleted; ones with check-ins must be cancelled instead), plus lateJoinEnabled, lateJoinUntilSessionIndex, latePricing, lateFixedPrice, checkInWindow, sessionReminders, certificatesEnabled and certificateConfig. The response includes sessionChanges.',
pathParams:{ ':id':'Event UUID' },
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
@@ -660,6 +661,8 @@ app.get('/docs', async (req, res) => {
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
@@ -739,6 +742,20 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]},
{ title: 'Attendance & certificates', base: '/api/attendance', endpoints: [
{ method:'GET', path:'/api/attendance/:eventId', auth:'staff+', desc:'Attendance register: sessions (columns), one row per main ticket with check-ins per session, completion and certificate',
responses:[{ status:200, desc:'Register', body:{ event:{ id:'ev-uuid-...', scheduleType:'sessions' }, columns:[{ id:'ses-uuid-...', label:'Week 1', startsAt:'2026-11-04T16:30:00.000Z', cancelled:false }], rows:[{ ticketId:'t-uuid-...', name:'Thandi', checkIns:{ 'ses-uuid-...':1 }, attended:1, total:5, completed:false, certificate:null }], totals:{ 'ses-uuid-...':12 } }}]},
{ method:'GET', path:'/api/attendance/:eventId/export?format=xlsx|pdf', auth:'staff+', desc:'Download the register as Excel or PDF' },
{ method:'POST', path:'/api/attendance/:eventId/mark', auth:'supervisor+', desc:'Mark a ticket present/absent for a session (single-date events use sessionId "event")', request:{ body:{ ticketId:'t-uuid-...', sessionId:'ses-uuid-...', present:true }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates', auth:'supervisor+', desc:'Issue certificates now to everyone eligible, or to one ticket (even if not eligible)', request:{ body:{ ticketId:'t-uuid-... (optional)' }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates/send', auth:'supervisor+', desc:'Email/WhatsApp unsent certificates, or one again', request:{ body:{ certificateId:'(optional)' }} },
{ method:'GET', path:'/api/attendance/:eventId/certificates.pdf', auth:'supervisor+', desc:'All of an event\'s certificates in one PDF' },
{ method:'POST', path:'/api/attendance/certificates/preview', auth:'supervisor+', desc:'Sample certificate PDF with unsaved settings', request:{ body:{ title:'Alpha Course', config:{ template:'classic' } }} },
{ method:'POST', path:'/api/attendance/certificates/:id/revoke', auth:'supervisor+', desc:'Revoke a certificate (never re-issued automatically)' },
{ method:'GET', path:'/api/attendance/certificates/mine', auth:'user+', desc:'My certificates' },
{ method:'GET', path:'/api/attendance/certificates/:id/pdf', auth:'user+', desc:'Download a certificate (its person, the booker, or a supervisor)' },
{ method:'GET', path:'/api/attendance/certificates/verify/:code', auth:'public', desc:'Check a certificate code — name, event and dates only', responses:[{ status:200, desc:'Result', body:{ valid:true, name:'Thandi Mokoena', event:'Alpha Course', completedOn:'2026-12-02T18:30:00.000Z' }}] },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
@@ -1430,199 +1447,9 @@ app.listen(PORT, () => {
console.warn('[attachments sync] Not scheduled:', e?.message || e);
}
// Schedule daily summaries at 07:00 local time (configurable)
try {
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendDailyEventSummaries } = require('./utils/notifications');
function msUntilNext(hour, minute) {
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next.getTime() - now.getTime();
}
async function scheduleNext() {
const delay = msUntilNext(7, 0);
setTimeout(async () => {
try {
console.log('[daily summaries] Running daily event summaries...');
await sendDailyEventSummaries(new Date());
console.log('[daily summaries] Completed sending daily event summaries.');
} catch (e) {
console.error('[daily summaries] Failed:', e?.message || e);
} finally {
// Schedule next run
scheduleNext();
}
}, delay);
}
scheduleNext();
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
} else {
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
}
} catch (e) {
console.warn('[daily summaries] Not scheduled:', e?.message || e);
}
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
try {
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runWaitlistJob } = require('./utils/waitlist');
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runWaitlistJob();
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
} catch (e) {
console.warn('[waitlist] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runFeedbackJob } = require('./utils/feedback');
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runFeedbackJob();
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
} catch (e) {
console.warn('[feedback] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[feedback] Not scheduled:', e?.message || e);
}
// Daily parental-consent job — reminders for unanswered requests, and purging
// medical/emergency details a set number of days after each event.
try {
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
const runConsentJobs = async () => {
try {
const reminded = await sendConsentReminders(new Date());
const purged = await purgeConsentMedicalData(new Date());
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
} catch (e) {
console.warn('[consent] Daily job failed:', e?.message || e);
}
};
const scheduleConsent = () => {
const now = new Date();
const next = new Date(now);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
};
scheduleConsent();
console.log('[consent] Scheduler initialized (09:00 local time).');
} else {
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[consent] Not scheduled:', e?.message || e);
}
// Daily temp-file cleanup — removes files older than 7 days from /temp
try {
const { cleanupTempFiles } = require('./utils/cleanupTemp');
function scheduleTempCleanup() {
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(() => {
try {
const result = cleanupTempFiles();
if (result.deleted > 0 || result.errors > 0) {
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
}
} catch (e) {
console.warn('[temp cleanup] Failed:', e?.message || e);
} finally {
scheduleTempCleanup();
}
}, next.getTime() - now.getTime());
}
scheduleTempCleanup();
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
} catch (e) {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
// Scheduled emails worker (polling)
try {
@@ -1684,6 +1511,14 @@ app.listen(PORT, () => {
// Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down.
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
process.exit(0);
});
}
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
+267
View File
@@ -0,0 +1,267 @@
/**
* Background jobs — one registry, run by pg-boss (a job queue stored in Postgres).
*
* Why a queue instead of setInterval:
* - Schedules live in the database, so a job runs once per schedule even when more than
* one backend process is running (PM2 cluster, two servers on one database).
* - Every run is recorded (state, start/end, result or error), shown on the admin
* Background jobs page, which can also run a job straight away.
* - A run that fails is retried, and a slow run never overlaps the next one.
*
* Each job can be switched off with its env flag (e.g. WAITLIST_JOB_ENABLED=false), and
* its schedule changed with JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*\/5 * * * *").
* JOBS_ENABLED=false turns all of them off (used by the end-to-end tests).
* If the queue can't start (e.g. the database user can't create the pgboss schema), the
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
const JOBS = [
{
name: 'daily-summary', label: 'Daily event summary email', cron: '0 7 * * *', env: 'DAILY_SUMMARY_ENABLED',
run: async () => { await require('../utils/notifications').sendDailyEventSummaries(new Date()); return { sent: true }; },
},
{
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
run: () => require('../utils/waitlist').runWaitlistJob(),
},
{
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
run: () => require('../utils/feedback').runFeedbackJob(),
},
{
name: 'sessions', label: 'Session reminders and certificates', cron: '*/5 * * * *', env: 'SESSION_JOBS_ENABLED',
run: async () => {
const reminders = await require('../utils/sessionReminders').runSessionReminderJob();
const certificates = await require('../utils/certificates').runCertificateJob();
return { reminders, certificates };
},
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
const { sendConsentReminders, purgeConsentMedicalData } = require('../utils/consent');
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
},
},
{
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
},
{
name: 'backup', label: 'Database backup', cron: '0 2 * * *', env: 'BACKUP_ENABLED',
run: async () => ({ file: (await require('../utils/backupUtils').runBackup()).filename }),
},
{
name: 'temp-cleanup', label: 'Delete temp files older than 7 days', cron: '0 3 * * *', env: null,
run: async () => require('../utils/cleanupTemp').cleanupTempFiles(),
},
];
const flag = (name, def = true) => {
const v = process.env[name];
return v === undefined || v === '' ? def : String(v).toLowerCase() !== 'false';
};
/** Whether a job is switched on (JOBS_ENABLED and its own flag). */
function isEnabled(job) {
return flag('JOBS_ENABLED') && (!job.env || flag(job.env));
}
/** A job's cron schedule, with any JOB_SCHEDULE_<NAME> override (checked). */
function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
}
}
return job.cron;
}
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
try {
const s = JSON.stringify(result ?? null);
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
} catch {
return '';
}
}
/** True when a job's result says nothing happened (all zeros/false/empty). */
function isNoop(value) {
if (value === null || value === undefined || value === 0 || value === false || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.values(value).every(isNoop);
return false;
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
// Quiet when nothing happened, like the old timers.
if (!isNoop(result)) console.log(`[jobs] ${job.name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
// ─── Runner ───────────────────────────────────────────────────────────────────
let boss = null;
let mode = 'off'; // 'queue' | 'timers' | 'off'
const timers = new Map();
// Timer mode keeps its own short history so the admin page still has something to show.
const timerHistory = new Map();
function startTimers(jobs) {
mode = 'timers';
for (const job of jobs) {
const cron = cronFor(job);
const scheduleNext = () => {
const delay = Math.max(1000, nextRun(cron).getTime() - Date.now());
timers.set(job.name, setTimeout(async () => {
const entry = { state: 'active', startedOn: new Date(), completedOn: null, output: null };
try {
entry.output = await runJob(job);
entry.state = 'completed';
} catch (e) {
entry.state = 'failed';
entry.output = { message: e?.message || String(e) };
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
} finally {
entry.completedOn = new Date();
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
scheduleNext();
}
}, Math.min(delay, 2 ** 31 - 1)));
};
scheduleNext();
}
console.log(`[jobs] Running ${jobs.length} job(s) on in-process timers.`);
}
/**
* Starts the background jobs. Call once after the server is listening.
* @returns {Promise<'queue'|'timers'|'off'>}
*/
async function startJobs() {
const enabled = JOBS.filter(isEnabled);
if (!flag('JOBS_ENABLED')) {
console.log('[jobs] All background jobs disabled by env JOBS_ENABLED=false');
mode = 'off';
return mode;
}
try {
const PgBoss = require('pg-boss');
boss = new PgBoss({ connectionString: process.env.DATABASE_URL, schema: SCHEMA, application_name: 'hope-events-jobs' });
boss.on('error', (e) => console.warn('[jobs] Queue error:', e?.message || e));
await boss.start();
const existing = new Set((await boss.getQueues()).map(q => q.name));
for (const job of JOBS) {
if (!existing.has(job.name)) {
// stately: at most one waiting and one running, so a slow run never piles up.
await boss.createQueue(job.name, { policy: 'stately', retryLimit: 2, retryDelay: 60, expireInSeconds: 60 * 60 });
}
if (isEnabled(job)) {
await boss.schedule(job.name, cronFor(job), null, { tz: TIMEZONE });
await boss.work(job.name, async () => {
try {
return await runJob(job);
} catch (e) {
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
});
} else {
await boss.unschedule(job.name).catch(() => {});
}
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
} catch (e) {
console.warn('[jobs] Job queue unavailable, falling back to timers:', e?.message || e);
if (boss) { try { await boss.stop({ graceful: false }); } catch { /* ignore */ } }
boss = null;
startTimers(enabled);
}
return mode;
}
async function stopJobs() {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
if (boss) {
await boss.stop({ graceful: true, timeout: 10000 }).catch(() => {});
boss = null;
}
mode = 'off';
}
/** Runs a job now (admin "Run now"). Queue mode: queued for a worker; timer mode: inline. */
async function runNow(name) {
const job = JOBS.find(j => j.name === name);
if (!job) {
const err = new Error('Unknown job');
err.statusCode = 404;
throw err;
}
if (mode === 'queue' && isEnabled(job)) {
const id = await boss.send(job.name, null, { singletonKey: `${job.name}-manual` });
return { queued: true, id };
}
const output = await runJob(job);
const entry = { state: 'completed', startedOn: new Date(), completedOn: new Date(), output };
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
return { queued: false, output };
}
/** Each job with its schedule, next run and recent runs, for the admin page. */
async function jobStatus(prisma) {
const recent = new Map();
if (mode === 'queue') {
const rows = await prisma.$queryRawUnsafe(`
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
ORDER BY "createdOn" DESC`, JOBS.map(j => j.name));
for (const r of rows) recent.set(r.name, [...(recent.get(r.name) || []), r]);
} else {
for (const [name, list] of timerHistory) recent.set(name, list);
}
return {
mode,
timezone: TIMEZONE,
jobs: JOBS.map(job => {
const cron = cronFor(job);
const enabled = isEnabled(job);
return {
name: job.name, label: job.label, cron, enabled, envFlag: job.env,
nextRun: enabled && mode !== 'off' ? nextRun(cron) : null,
recent: recent.get(job.name) || [],
};
}),
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
+3
View File
@@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { listJobs, runJobNow } = require('../controllers/jobsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/jobs', protect, admin, listJobs);
router.post('/jobs/:name/run', protect, admin, runJobNow);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const {
getRegister, markAttendance, exportRegister,
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
myCertificates, downloadCertificate, verifyCertificate,
} = require('../controllers/attendanceController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
// Certificates — fixed paths first so they aren't taken as an :eventId
router.get('/certificates/verify/:code', verifyCertificate);
router.get('/certificates/mine', protect, myCertificates);
router.post('/certificates/preview', protect, supervisor, previewCertificate);
router.get('/certificates/:id/pdf', protect, downloadCertificate);
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
// Attendance register
router.get('/:eventId', protect, staff, getRegister);
router.get('/:eventId/export', protect, staff, exportRegister);
router.post('/:eventId/mark', protect, supervisor, markAttendance);
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
module.exports = router;
+365
View File
@@ -0,0 +1,365 @@
/**
* Completion certificates — optional per event (Event.certificatesEnabled).
*
* Rules:
* - One certificate per main ticket (one person). Add-on tickets never get one.
* - Eligible when the ticket's attendance meets the event's completion rule
* (multi-day: sessions attended out of sessions held; single date: checked in).
* - Issued automatically once the event has ended, or by a supervisor at any time
* ("Issue now" for everyone eligible, or one person manually even if not eligible).
* - A revoked certificate stays revoked: the automatic job never re-issues it.
* - Each certificate has a short code that the public verify page checks.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const prisma = require('../config/db');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const AUTO_ISSUE_DAYS = 14;
/** A random code like "K7M2Q-9XRTB" (no 0/O/1/I to avoid misreading). */
function newCode() {
const bytes = crypto.randomBytes(10);
const chars = Array.from(bytes, b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
return `${chars.slice(0, 5)}-${chars.slice(5)}`;
}
/** Fills {name}, {event}, {sessionsAttended}, {totalSessions}, {endDate} in certificate text. */
function fillPlaceholders(text, values) {
return String(text || '').replace(/\{(name|event|sessionsAttended|totalSessions|endDate)\}/g, (_, k) => String(values[k] ?? ''));
}
function fmtLongDate(d) {
return new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, day: 'numeric', month: 'long', year: 'numeric' });
}
function ticketName(t) {
return t.attendee?.name || t.holder?.name || t.user?.name || 'Attendee';
}
/**
* Attendance per main ticket of an event: [{ ticket, attended, total }]. Multi-day events
* count sessions attended (cancelled sessions left out); single-date events count 1 when
* checked in.
*/
async function attendanceForCertificates(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { sessions: true } });
if (!event) return { event: null, rows: [] };
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
include: {
attendee: { select: { name: true } },
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true } },
},
orderBy: { createdAt: 'asc' },
});
if (event.scheduleType === 'sessions') {
const active = activeSessions(event.sessions);
const activeIds = new Set(active.map(s => s.id));
const bySession = await attendanceBySession(eventId);
// A picked-session booking (one ticket per session) is one person: its first ticket
// carries the certificate, and it's measured against the sessions it booked.
return {
event,
rows: groupTickets(tickets).map(g => {
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
const attendedIds = new Set();
for (const t of g.tickets) {
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
}
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
}),
};
}
return { event, rows: tickets.map(t => ({ ticket: t, attended: t.usages.length > 0 ? 1 : 0, total: 1 })) };
}
/**
* Issues certificates for everyone who meets the completion rule and doesn't have one yet
* (revoked ones are left alone). Returns the new certificates.
*/
async function issueEligible(eventId, { issuedById = null } = {}) {
const { event, rows } = await attendanceForCertificates(eventId);
if (!event || !event.certificatesEnabled) return [];
const config = normalizeCertificateConfig(event.certificateConfig);
const existing = new Set((await prisma.certificate.findMany({ where: { eventId }, select: { ticketId: true } })).map(c => c.ticketId));
const created = [];
for (const r of rows) {
if (existing.has(r.ticket.id) || !completionMet(config.rule, r.attended, r.total)) continue;
try {
created.push(await prisma.certificate.create({
data: {
eventId, ticketId: r.ticket.id, userId: r.ticket.holderUserId || r.ticket.userId,
name: ticketName(r.ticket), code: newCode(), sessionsAttended: r.attended, totalSessions: r.total,
issuedById,
},
}));
} catch (e) {
if (e?.code !== 'P2002') throw e; // issued at the same moment by the job — fine
}
}
return created;
}
/** Issues (or re-issues a revoked) certificate for one ticket, eligible or not. */
async function issueManual(eventId, ticketId, issuedById) {
const { event, rows } = await attendanceForCertificates(eventId);
const r = rows.find(x => x.ticket.id === ticketId);
if (!event || !r) {
const err = new Error('Ticket not found for this event');
err.statusCode = 404;
throw err;
}
const data = { name: ticketName(r.ticket), sessionsAttended: r.attended, totalSessions: r.total, manual: true, issuedById, revokedAt: null, issuedAt: new Date() };
return prisma.certificate.upsert({
where: { eventId_ticketId: { eventId, ticketId } },
create: { eventId, ticketId, userId: r.ticket.holderUserId || r.ticket.userId, code: newCode(), ...data },
update: data,
});
}
// ─── PDF ──────────────────────────────────────────────────────────────────────
const PAGE = { size: 'A4', layout: 'landscape', margin: 0 };
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return [30, 58, 95];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lighten(hex, amount) {
const [r, g, b] = hexToRgb(hex);
const mix = (c) => Math.round(c + (255 - c) * amount);
return `#${[mix(r), mix(g), mix(b)].map(c => c.toString(16).padStart(2, '0')).join('')}`;
}
function uploadPath(url) {
if (!url || /^https?:\/\//i.test(url)) return null;
const p = path.join(__dirname, '..', '..', 'public', String(url).replace(/^\//, ''));
return fs.existsSync(p) ? p : null;
}
function safeImage(doc, file, x, y, opts) {
if (!file) return false;
try { doc.image(file, x, y, opts); return true; } catch { return false; }
}
// Shared bottom block: signature (left), date (middle), verification QR + code (right).
function drawFooter(doc, { cert, config, branding, qr, color, font, bold, y }) {
const W = doc.page.width;
const colW = 200;
const sigX = 90;
const sigImg = uploadPath(config.signatureImage);
if (sigImg) safeImage(doc, sigImg, sigX + 20, y - 46, { fit: [160, 42] });
doc.moveTo(sigX, y).lineTo(sigX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
doc.font(bold).fontSize(11).fillColor('#1e293b').text(config.signatureName || branding.name, sigX, y + 6, { width: colW, align: 'center' });
if (config.signatureRole) doc.font(font).fontSize(9).fillColor('#64748b').text(config.signatureRole, sigX, y + 21, { width: colW, align: 'center' });
const dateX = W / 2 - colW / 2;
doc.moveTo(dateX, y).lineTo(dateX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
doc.font(bold).fontSize(11).fillColor('#1e293b').text(fmtLongDate(cert.issuedAt), dateX, y + 6, { width: colW, align: 'center' });
doc.font(font).fontSize(9).fillColor('#64748b').text('Date', dateX, y + 21, { width: colW, align: 'center' });
const qrSize = 64;
const qrX = W - 90 - qrSize;
safeImage(doc, qr, qrX, y - 50, { width: qrSize });
doc.font(font).fontSize(7.5).fillColor('#64748b').text(`Verify: ${cert.code}`, qrX - 40, y + 18, { width: qrSize + 80, align: 'center' });
doc.fillColor(color);
}
function drawTemplate(doc, template, ctx) {
const { cert, config, branding, event, title, body } = ctx;
const W = doc.page.width;
const H = doc.page.height;
const color = branding.brandColor;
const logo = branding.logoPath;
if (template === 'modern') {
doc.rect(0, 0, W, H).fill('#ffffff');
doc.rect(0, 0, 28, H).fill(color);
doc.rect(28, 0, 6, H).fill(lighten(color, 0.6));
const x = 90;
safeImage(doc, logo, x, 70, { fit: [120, 50] });
doc.font('Helvetica').fontSize(11).fillColor(color).text(branding.name.toUpperCase(), x, 150, { characterSpacing: 2 });
doc.font('Helvetica-Bold').fontSize(36).fillColor('#0f172a').text(title, x, 175, { width: W - x - 80 });
doc.font('Helvetica').fontSize(13).fillColor('#64748b').text('Presented to', x, 260);
doc.font('Helvetica-Bold').fontSize(32).fillColor(color).text(cert.name, x, 280, { width: W - x - 80 });
doc.font('Helvetica').fontSize(14).fillColor('#334155').text(body, x, 340, { width: W - x - 140, lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 80 });
return;
}
if (template === 'church') {
doc.rect(0, 0, W, H).fill(lighten(color, 0.94));
doc.rect(30, 30, W - 60, H - 60).lineWidth(1).strokeColor(lighten(color, 0.4)).stroke();
const hasLogo = safeImage(doc, logo, W / 2 - 50, 55, { fit: [100, 70], align: 'center' });
const top = hasLogo ? 150 : 115;
doc.font('Times-Italic').fontSize(14).fillColor(color).text(branding.name, 0, top, { width: W, align: 'center' });
doc.font('Times-Bold').fontSize(34).fillColor('#1e293b').text(title, 60, top + 26, { width: W - 120, align: 'center' });
doc.moveTo(W / 2 - 60, top + 74).lineTo(W / 2 + 60, top + 74).lineWidth(1.5).strokeColor(color).stroke();
doc.font('Times-Italic').fontSize(14).fillColor('#475569').text('This is to certify that', 0, top + 88, { width: W, align: 'center' });
doc.font('Times-Bold').fontSize(30).fillColor(color).text(cert.name, 60, top + 110, { width: W - 120, align: 'center' });
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, top + 162, { width: W - 240, align: 'center', lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
return;
}
if (template === 'kids') {
const bright = ['#f59e0b', '#ec4899', '#22c55e', '#3b82f6', '#a855f7'];
doc.rect(0, 0, W, H).fill('#fffdf5');
doc.roundedRect(22, 22, W - 44, H - 44, 28).lineWidth(10).strokeColor(color).stroke();
// Confetti dots around the edge.
let seed = parseInt(cert.code.replace(/[^A-Z0-9]/g, '').slice(0, 6), 36) || 7;
const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
// Confetti in the top band and down the sides — never over the text or the footer.
for (let i = 0; i < 46; i++) {
const top = rnd() < 0.45;
const x = top ? 50 + rnd() * (W - 100) : (rnd() < 0.5 ? 48 + rnd() * 45 : W - 93 + rnd() * 45);
const y = top ? 48 + rnd() * 50 : 110 + rnd() * (H - 260);
doc.circle(x, y, 3 + rnd() * 5).fill(bright[i % bright.length]);
}
safeImage(doc, logo, W / 2 - 40, 100, { fit: [80, 50], align: 'center' });
doc.font('Helvetica-Bold').fontSize(38).fillColor(color).text(title, 100, 165, { width: W - 200, align: 'center' });
doc.font('Helvetica-Bold').fontSize(15).fillColor('#f59e0b').text('Well done,', 0, 230, { width: W, align: 'center' });
doc.font('Helvetica-Bold').fontSize(36).fillColor('#1e293b').text(cert.name, 100, 250, { width: W - 200, align: 'center' });
doc.font('Helvetica').fontSize(15).fillColor('#334155').text(body, 130, 310, { width: W - 260, align: 'center', lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 90 });
return;
}
// classic
doc.rect(0, 0, W, H).fill('#fffefa');
doc.rect(24, 24, W - 48, H - 48).lineWidth(3).strokeColor(color).stroke();
doc.rect(34, 34, W - 68, H - 68).lineWidth(0.8).strokeColor(color).stroke();
for (const [cx, cy] of [[34, 34], [W - 34, 34], [34, H - 34], [W - 34, H - 34]]) {
doc.circle(cx, cy, 6).fill(color);
}
safeImage(doc, logo, W / 2 - 45, 70, { fit: [90, 55], align: 'center' });
doc.font('Times-Bold').fontSize(38).fillColor('#1e293b').text(title.toUpperCase(), 60, 150, { width: W - 120, align: 'center', characterSpacing: 1.5 });
doc.font('Times-Italic').fontSize(14).fillColor('#64748b').text('This certificate is proudly presented to', 0, 212, { width: W, align: 'center' });
doc.font('Times-BoldItalic').fontSize(34).fillColor(color).text(cert.name, 60, 236, { width: W - 120, align: 'center' });
doc.moveTo(W / 2 - 170, 284).lineTo(W / 2 + 170, 284).lineWidth(0.8).strokeColor('#cbd5e1').stroke();
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, 300, { width: W - 240, align: 'center', lineGap: 5 });
doc.font('Times-Italic').fontSize(11).fillColor('#64748b').text(branding.name, 0, H - 132, { width: W, align: 'center' });
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
void event;
}
/**
* Renders certificates (one page each) to a PDF Buffer.
* @param {object[]} certs - Certificate rows ({ name, code, issuedAt, sessionsAttended, totalSessions })
* @param {object} event - { title, endDate, certificateConfig }
* @param {object} [configOverride] - certificate settings to use instead (editor preview)
*/
async function renderCertificatesPdf(certs, event, configOverride = null) {
const { getBranding } = require('./pdfDocs');
const branding = await getBranding();
const config = normalizeCertificateConfig(configOverride || event.certificateConfig);
const doc = new PDFDocument({ ...PAGE, autoFirstPage: false, info: { Title: `${config.title} — ${event.title}` } });
const chunks = [];
doc.on('data', c => chunks.push(c));
const done = new Promise((resolve, reject) => { doc.on('end', resolve); doc.on('error', reject); });
for (const cert of certs) {
doc.addPage(PAGE);
const values = { name: cert.name, event: event.title, sessionsAttended: cert.sessionsAttended, totalSessions: cert.totalSessions, endDate: fmtLongDate(event.endDate) };
const qrData = await QRCode.toDataURL(`${branding.url}/certificates/verify/${encodeURIComponent(cert.code)}`, { margin: 0, width: 200 });
const qr = Buffer.from(qrData.split(',')[1], 'base64');
drawTemplate(doc, config.template, {
cert, config, branding, event, qr,
title: fillPlaceholders(config.title, values),
body: fillPlaceholders(config.body, values),
});
}
doc.end();
await done;
return Buffer.concat(chunks);
}
/** A sample certificate for the event editor's preview button. */
function sampleCertificate() {
return { name: 'Thandi Mokoena', code: 'SAMPL-E2026', issuedAt: new Date(), sessionsAttended: 5, totalSessions: 5 };
}
// ─── Sending ──────────────────────────────────────────────────────────────────
/** Emails (and WhatsApps, per preference) each certificate to its person. Returns how many went out. */
async function sendCertificates(certIds) {
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
const { shouldEmail, waPdf, waPdfAny } = require('./notify');
const certs = await prisma.certificate.findMany({
where: { id: { in: certIds }, revokedAt: null },
include: {
event: { select: { id: true, title: true, endDate: true, certificateConfig: true } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
},
});
const org = getOrg();
let sent = 0;
for (const cert of certs) {
const user = cert.user;
if (!user) continue;
try {
const pdf = await renderCertificatesPdf([cert], cert.event);
const filename = `Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + '.pdf';
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
const jobs = [];
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p>Hi ${user.name?.split(' ')[0] || 'there'},</p>
<p>Congratulations on completing <strong>${cert.event.title}</strong>! Your certificate for <strong>${cert.name}</strong> is attached.</p>
${ctaButton('View my events', `${org.url}/dashboard/user`)}
<p style="font-size:13px;color:#64748b">Anyone can check it at ${org.url}/certificates/verify/${cert.code}</p>`, { preheader: `Your certificate for ${cert.event.title}` });
jobs.push(sendMail({ to: user.email, subject: `Your certificate — ${cert.event.title}`, html, text: `Congratulations on completing ${cert.event.title}! Your certificate is attached.`, attachments: [{ filename, content: pdf, contentType: 'application/pdf' }] }));
}
const { tempDir } = require('./pdfDocs');
const tmp = path.join(tempDir(), `certificate-${cert.id}.pdf`);
fs.writeFileSync(tmp, pdf);
const caption = `🎓 Your certificate for ${cert.event.title}`;
jobs.push(hasEmail ? waPdf(user, tmp, filename, caption) : waPdfAny(user, tmp, filename, caption));
await Promise.all(jobs);
await prisma.certificate.update({ where: { id: cert.id }, data: { emailedAt: new Date() } });
sent += 1;
} catch (e) {
console.warn('[certificates] Send failed:', e?.message || e);
}
}
return sent;
}
/**
* Background job: for events with certificates on that ended within the last 14 days,
* issue certificates to everyone eligible, and send them when the event says so.
*/
async function runCertificateJob(now = new Date()) {
const since = new Date(now.getTime() - AUTO_ISSUE_DAYS * 24 * 60 * 60 * 1000);
const events = await prisma.event.findMany({
where: { certificatesEnabled: true, endDate: { lte: now, gte: since } },
select: { id: true, certificateConfig: true },
});
let issued = 0;
let sent = 0;
for (const ev of events) {
const created = await issueEligible(ev.id);
issued += created.length;
if (created.length && normalizeCertificateConfig(ev.certificateConfig).sendOnIssue) {
sent += await sendCertificates(created.map(c => c.id));
}
}
return { issued, sent };
}
module.exports = {
newCode, fillPlaceholders, attendanceForCertificates, issueEligible, issueManual,
renderCertificatesPdf, sampleCertificate, sendCertificates, runCertificateJob,
};
+1
View File
@@ -423,6 +423,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
module.exports = {
sendMail,
isSmtpConfigured,
getOrg,
emailWrapper,
ctaButton,
fallbackLink,
+35 -15
View File
@@ -1,4 +1,4 @@
const { createEvent } = require('ics');
const { createEvent, createEvents } = require('ics');
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
// datetime-local input is parsed in the browser's local time before being sent as an
@@ -9,26 +9,46 @@ function toUtcArray(date) {
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
}
/**
* Build an RFC 5545 .ics file (as a string) for a single event.
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const { error, value } = createEvent({
title: event.title,
start: toUtcArray(event.startDate),
end: toUtcArray(event.endDate),
function icsEntry(event, eventUrl, { start, end, title, location }) {
return {
title,
start: toUtcArray(start),
end: toUtcArray(end),
startInputType: 'utc',
endInputType: 'utc',
startOutputType: 'utc',
endOutputType: 'utc',
location: event.location || undefined,
location: location || event.location || undefined,
description: event.description || undefined,
url: eventUrl,
});
};
}
/**
* Build an RFC 5545 .ics file (as a string) for an event. A multi-session event gets one
* calendar entry per session that isn't cancelled (not a repeat rule, so moved or skipped
* sessions are exact).
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?,
* scheduleType?, sessions?[]
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const sessions = event.scheduleType === 'sessions'
? (event.sessions || []).filter(s => !s.cancelledAt).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
: [];
if (sessions.length > 0) {
const { error, value } = createEvents(sessions.map((s, i) => icsEntry(event, eventUrl, {
start: s.startsAt,
end: s.endsAt,
title: `${event.title} — ${s.label || `Session ${i + 1}`}`,
location: s.location,
})));
if (error) throw error;
return value;
}
const { error, value } = createEvent(icsEntry(event, eventUrl, { start: event.startDate, end: event.endDate, title: event.title }));
if (error) throw error;
return value;
}
+1 -1
View File
@@ -387,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber, tempDir };
+4 -2
View File
@@ -30,6 +30,7 @@
*/
const prisma = require('../config/db');
const { withLatePrice } = require('./sessions');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
@@ -91,8 +92,9 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
// Late joiners on a multi-day event pay a lower main-ticket price (utils/sessions.js).
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
return withLatePrice(option, withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user)));
}
async function resolveOptionTierPrice(option, requestedQty) {
@@ -147,7 +149,7 @@ async function resolveOptionTierPrice(option, requestedQty) {
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
return withLatePrice(option, withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user)));
}
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
+29 -5
View File
@@ -14,6 +14,8 @@
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
* scans are still recorded, and the report is flagged.
* - Multi-day events: the snapshot counts what's left for the session running when it was
* downloaded, and each synced scan counts for the session at its scan time.
*/
const prisma = require('../config/db');
@@ -53,6 +55,8 @@ function lockCovers(lock, { sectionId = null, optionId }) {
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
@@ -69,6 +73,8 @@ const CONFLICT_TEXT = {
already_used: 'Ticket had already been fully used',
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
wrong_session: 'Ticket is for a different session',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
@@ -123,7 +129,7 @@ async function snapshotFor(lock) {
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
},
include: {
usages: { select: { quantityRedeemed: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
attendee: { select: { name: true } },
user: { select: { name: true } },
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
@@ -133,10 +139,19 @@ async function snapshotFor(lock) {
const consent = await consentStatusForTickets(tickets.map(t => t.id));
const snapshotAt = new Date();
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
const sessionsUtil = require('./sessions');
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
const session = schedule?.scheduleType === 'sessions' ? sessionsUtil.currentSession(schedule.sessions, snapshotAt, schedule.checkInWindow) : null;
return {
snapshotAt,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
tickets: tickets.map(t => {
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
// A picked-session ticket for another session has nothing left today.
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
const redeemed = schedule?.scheduleType === 'sessions'
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
qrCode: t.qrCode,
@@ -161,6 +176,9 @@ async function syncScans(lock, scans) {
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
const { consentStatusForTickets } = require('./consent');
const checkIns = [];
const sessionsUtil = require('./sessions');
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
const hasSessions = schedule?.scheduleType === 'sessions';
for (const scan of (scans || []).slice(0, 2000)) {
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
@@ -178,9 +196,15 @@ async function syncScans(lock, scans) {
const { findTransferByOldCode } = require('./ticketTransfer');
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
} else if (ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const session = hasSessions ? sessionsUtil.currentSession(schedule.sessions, scannedAt, schedule.checkInWindow) : null;
const redeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
sessionId: session?.id || null,
noSession: hasSessions && !session,
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
@@ -190,9 +214,9 @@ async function syncScans(lock, scans) {
const verdict = judgeSyncedScan(state, qty);
if (verdict.redeem > 0) {
await prisma.ticketUsage.create({
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date(), sessionId: state.sessionId },
});
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: !hasSessions && state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
results.accepted += 1;
if (ticket.registrationOption.eventOption.isMainTicket) {
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
+122
View File
@@ -0,0 +1,122 @@
/**
* Session reminders — optional per multi-day event (Event.sessionReminders), e.g. "3 h
* before" and "1 day before" each session, by email and/or WhatsApp.
*
* Rules:
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
* an account), skipping cancelled registrations. One message per person per session.
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
* already due — e.g. reminders were switched on 2 h before a session — only the
* closest one is sent and the others are logged as skipped, so nobody gets two at once.
* - Cancelled sessions get no reminders. Email/WhatsApp follow each person's
* notification preference.
*/
const prisma = require('../config/db');
const { activeSessions, sessionLabel, TIMEZONE } = require('./sessions');
const MAX_OFFSET_MIN = 1440;
function fmtWhen(d) {
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
}
/** People to remind about a session: whole-course tickets plus picked places on it. Map userId → user. */
async function recipientsFor(eventId, sessionId) {
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null }, { sessionId }] } },
select: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
},
});
const out = new Map();
for (const t of tickets) {
for (const u of [t.user, t.holder]) if (u && !out.has(u.id)) out.set(u.id, u);
}
return out;
}
async function sendReminder(user, event, session, sessions) {
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
const { shouldEmail, waText, waTextAny } = require('./notify');
const org = getOrg();
const label = sessionLabel(session, sessions);
const when = fmtWhen(session.startsAt);
const where = session.location || event.location || '';
const first = (user.name || '').split(' ')[0] || 'there';
const url = `${org.url}/events/${event.id}`;
const jobs = [];
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p>Hi ${first},</p>
<p>A reminder that <strong>${event.title} — ${label}</strong> is on <strong>${when}</strong>${where ? ` at <strong>${where}</strong>` : ''}.</p>
<p>Bring your ticket QR code (it's the same one for every session).</p>
${ctaButton('View event', url)}`, { preheader: `${label} — ${when}` });
jobs.push(sendMail({ to: user.email, subject: `Reminder: ${event.title} — ${label}`, html, text: `Reminder: ${event.title} — ${label} is on ${when}${where ? ` at ${where}` : ''}. ${url}` }));
}
const wa = `⏰ *Reminder*\n\nHi ${first}, *${event.title} — ${label}* is on ${when}${where ? `\n📍 ${where}` : ''}.\n\nBring your ticket QR code — it's the same one for every session.\n${url}`;
jobs.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
await Promise.all(jobs);
}
/**
* Sends the reminders that are due. Returns { sessions, sent } counts.
*/
async function runSessionReminderJob(now = new Date()) {
// (Filtering a Json column on null is awkward in Prisma, so events without reminders
// are skipped below instead.)
const events = await prisma.event.findMany({
where: { scheduleType: 'sessions', isActive: true, endDate: { gte: now } },
include: {
sessions: {
where: { cancelledAt: null, startsAt: { gt: now, lte: new Date(now.getTime() + MAX_OFFSET_MIN * 60 * 1000) } },
include: { reminders: true },
},
},
});
let sessionsDone = 0;
let sent = 0;
for (const event of events) {
if (!event.sessionReminders || event.sessions.length === 0) continue;
const config = event.sessionReminders;
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
if (offsets.length === 0) continue;
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
for (const session of event.sessions) {
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
const due = offsets.filter(o => minutesLeft <= o && !loggedOffsets.has(o));
if (due.length === 0) continue;
// Claim every due offset first (unique per session+offset), so a second server
// process running the same job can't send them again.
const claimed = [];
for (const o of due) {
try {
await prisma.sessionReminderLog.create({ data: { sessionId: session.id, offset: o } });
claimed.push(o);
} catch (e) {
if (e?.code !== 'P2002') throw e;
}
}
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
const recipients = await recipientsFor(event.id, session.id);
let count = 0;
for (const user of recipients.values()) {
try {
await sendReminder(user, event, session, activeSessions(allSessions));
count += 1;
} catch (e) {
console.warn('[session reminders] Send failed:', e?.message || e);
}
}
await prisma.sessionReminderLog.update({ where: { sessionId_offset: { sessionId: session.id, offset: due[0] } }, data: { sent: count } });
sessionsDone += 1;
sent += count;
}
}
return { sessions: sessionsDone, sent };
}
module.exports = { runSessionReminderJob };
+493
View File
@@ -0,0 +1,493 @@
/**
* Multi-day events — one event running on several dates ("sessions"), e.g. every
* Wednesday for 5 weeks.
*
* Rules:
* - Event.startDate/endDate always mirror the first active session's start and the last
* active session's end, so everything that only reads those keeps working.
* - A cancelled session is kept (it may have check-ins) but is hidden from the schedule
* and left out of totals. A session with check-ins can't be deleted, only cancelled.
* - Check-in is once per session. "Whole day" accepts a scan any time on the session's
* date (in the church's timezone); "session only" from 2 h before the start until the end.
* - Registration closes when the first session starts, or — with late joining — when
* session N starts. A registration deadline, if set, still wins when it's earlier.
* - Late pricing applies to main tickets only, for people registering after the first
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
* bought per session — one registration line (and ticket) per session, priced per
* session, bookable until that session starts. `all` options cover every session and
* follow the whole-course rules above. A session's capacity counts picked places plus
* whole-course main tickets; a pick option's stock limit is places per session.
* Not available on youth (parental consent) events, and picked lines take no names.
*/
const prisma = require('../config/db');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const EARLY_CHECK_IN_MS = 2 * 60 * 60 * 1000;
const MAX_SESSIONS = 200;
const REMINDER_OFFSETS = [60, 180, 1440]; // minutes before start: 1 h, 3 h, 1 day
const CERTIFICATE_TEMPLATES = ['classic', 'modern', 'church', 'kids'];
function sessionError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** 'YYYY-MM-DD' of a moment in the church's timezone. */
function dayKey(date, timeZone = TIMEZONE) {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
}
/** Sessions that aren't cancelled, earliest first. */
function activeSessions(sessions) {
return (sessions || [])
.filter(s => !s.cancelledAt)
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
}
/** { startDate, endDate } covering the active sessions, or null when there are none. */
function scheduleBounds(sessions) {
const active = activeSessions(sessions);
if (active.length === 0) return null;
const endDate = active.reduce((max, s) => (new Date(s.endsAt) > max ? new Date(s.endsAt) : max), new Date(active[0].endsAt));
return { startDate: new Date(active[0].startsAt), endDate };
}
/**
* Checks and tidies the sessions sent by the event editor.
* Each: { id?, label?, startsAt, endsAt, location?, cancelled? }.
*/
function normalizeSessionsInput(list) {
if (!Array.isArray(list)) throw sessionError('Sessions must be a list');
if (list.length > MAX_SESSIONS) throw sessionError(`An event can have at most ${MAX_SESSIONS} sessions`);
const out = list.map((s, i) => {
const startsAt = new Date(s?.startsAt);
const endsAt = new Date(s?.endsAt);
if (isNaN(startsAt) || isNaN(endsAt)) throw sessionError(`Session ${i + 1} needs a start and end date`);
if (endsAt <= startsAt) throw sessionError(`Session ${i + 1} must end after it starts`);
return {
id: s.id ? String(s.id) : null,
label: s.label ? String(s.label).trim().slice(0, 120) || null : null,
startsAt,
endsAt,
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
cancelled: s.cancelled === true,
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
};
});
out.sort((a, b) => a.startsAt - b.startsAt);
if (!out.some(s => !s.cancelled)) throw sessionError('A multi-session event needs at least one session that isn\'t cancelled');
return out;
}
/** Whether a check-in for `session` is allowed at `now`. */
function sessionAcceptsCheckIn(session, now = new Date(), window = 'whole_day') {
if (!session || session.cancelledAt) return false;
const t = new Date(now);
if (window === 'session_only') {
return t >= new Date(new Date(session.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(session.endsAt);
}
const today = dayKey(t);
return today >= dayKey(session.startsAt) && today <= dayKey(session.endsAt);
}
/**
* The session a scan at `now` counts for, or null. On a day with more than one session:
* the one happening now (from 2 h before), else the next one today, else the last one.
*/
function currentSession(sessions, now = new Date(), window = 'whole_day') {
const t = new Date(now);
const open = activeSessions(sessions).filter(s => sessionAcceptsCheckIn(s, t, window));
if (open.length <= 1) return open[0] || null;
const running = open.find(s => t >= new Date(new Date(s.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(s.endsAt));
if (running) return running;
return open.find(s => new Date(s.startsAt) > t) || open[open.length - 1];
}
/** When public registration closes for an event (Date). */
/** When whole-course (all-sessions) tickets stop selling (Date). */
function wholeCourseClosesAt(event, sessions) {
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
let closes = new Date(event.endDate);
if (event.scheduleType === 'sessions') {
const active = activeSessions(sessions);
if (active.length > 0) {
const until = event.lateJoinEnabled ? active.find(s => s.id === event.lateJoinUntilSessionId) : null;
closes = new Date((until || active[0]).startsAt);
}
}
return deadline && deadline < closes ? deadline : closes;
}
/**
* When public registration closes (Date). "Pick sessions" events stay open until the
* last session starts (each session can be booked until it starts).
*/
function registrationClosesAt(event, sessions) {
const whole = wholeCourseClosesAt(event, sessions);
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
const active = activeSessions(sessions);
if (active.length === 0) return whole;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const last = new Date(active[active.length - 1].startsAt);
const closes = last > whole ? last : whole;
return deadline && deadline < closes ? deadline : closes;
}
/** Whether an option is bought per session (only in "pick sessions" events). */
function isPickOption(event, option) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
}
/**
* Turns requested lines into registration lines: a pick option with sessionIds becomes
* one line per session (same quantity); other options stay as they are. Checks the
* sessions can still be booked: public — before the session starts; staff — until it ends.
* @param {object} event - with eventOptions and sessions
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
*/
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
const out = [];
for (const line of lines || []) {
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
const pick = isPickOption(event, option);
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
if (!pick) {
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
out.push({ ...line, sessionId: null });
continue;
}
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
for (const id of ids) {
const session = (event.sessions || []).find(s => s.id === id);
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
const { sessionIds, ...rest } = line;
void sessionIds;
out.push({ ...rest, sessionId: id });
}
}
return out;
}
/**
* People booked per session: picked places on that session plus whole-course main
* tickets (which attend every session). Cancelled registrations don't count.
* @returns {Promise<Map<string, number>>}
*/
async function sessionBookings(eventId, db = prisma) {
const lines = await db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true },
});
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
const whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
const out = new Map(sessions.map(s => [s.id, whole]));
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
return out;
}
/**
* Checks requested lines fit each session's capacity. Whole-course main tickets count
* against every session; picked main tickets against their own.
*/
async function assertSessionCapacity(event, lines, db = prisma) {
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
if (limited.length === 0) return;
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
const wanted = new Map();
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
for (const s of limited) wanted.set(s.id, whole);
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
if ([...wanted.values()].every(n => n === 0)) return;
const booked = await sessionBookings(event.id, db);
for (const s of limited) {
const left = s.capacity - (booked.get(s.id) || 0);
if ((wanted.get(s.id) || 0) > left) {
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
}
}
}
/**
* Late-joiner pricing state at `now`: active once the first session has started (for
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
*/
function lateInfo(event, sessions, now = new Date()) {
const active = activeSessions(sessions);
const base = { active: false, mode: event.latePricing || 'full', sessionsLeft: active.length, totalSessions: active.length, fixedPrice: event.lateFixedPrice ?? null };
if (event.scheduleType !== 'sessions' || !event.lateJoinEnabled || active.length === 0) return base;
const t = new Date(now);
const sessionsLeft = active.filter(s => new Date(s.startsAt) > t).length;
const started = t >= new Date(active[0].startsAt);
const priced = base.mode === 'prorata' || (base.mode === 'fixed' && base.fixedPrice !== null);
return { ...base, sessionsLeft, active: started && priced };
}
/** A main ticket's price for a late joiner. Never more than the normal price. */
function latePrice(price, info) {
if (!info || !info.active || !(price > 0)) return price;
if (info.mode === 'fixed') return Math.min(price, Math.max(0, Number(info.fixedPrice)));
if (info.mode === 'prorata') {
if (!info.totalSessions) return price;
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
return Math.min(price, Math.max(10, rounded));
}
return price;
}
/** Whether `attended` of `total` sessions meets a certificate completion rule. */
function completionMet(rule, attended, total) {
if (total <= 0 || attended <= 0) return false;
const type = rule?.type || 'all';
const value = Number(rule?.value) || 0;
if (type === 'count') return attended >= Math.min(Math.max(1, value), total);
if (type === 'percent') return attended / total * 100 >= Math.min(Math.max(1, value), 100) - 1e-9;
return attended >= total;
}
/** Cleans the reminder settings from the editor; null = off. */
function normalizeReminders(input) {
if (!input || input.enabled === false) return null;
const offsets = [...new Set((input.offsets || []).map(Number).filter(n => REMINDER_OFFSETS.includes(n)))].sort((a, b) => b - a);
const channels = [...new Set((input.channels || []).filter(c => ['email', 'whatsapp'].includes(c)))];
if (offsets.length === 0 || channels.length === 0) return null;
return { offsets, channels };
}
/** Cleans the certificate settings from the editor. */
function normalizeCertificateConfig(input) {
const c = input && typeof input === 'object' ? input : {};
const ruleType = ['all', 'count', 'percent'].includes(c.rule?.type) ? c.rule.type : 'all';
const str = (v, max) => (v ? String(v).trim().slice(0, max) : '');
return {
template: CERTIFICATE_TEMPLATES.includes(c.template) ? c.template : 'classic',
title: str(c.title, 120) || 'Certificate of Completion',
body: str(c.body, 1000) || 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
signatureName: str(c.signatureName, 120),
signatureRole: str(c.signatureRole, 120),
signatureImage: str(c.signatureImage, 500),
rule: { type: ruleType, value: ruleType === 'all' ? null : Math.max(1, parseInt(c.rule?.value, 10) || 1) },
sendOnIssue: c.sendOnIssue === true,
};
}
// ─── Database helpers ─────────────────────────────────────────────────────────
/**
* Saves the editor's session list for an event: updates rows it kept, creates new ones,
* deletes rows it dropped (refused when they have check-ins — cancel those instead), and
* moves Event.startDate/endDate to match. Run inside a transaction (`tx`).
* @returns {Promise<{ sessions, changes: { added, moved, cancelled, restored, removed } }>}
*/
async function syncEventSessions(tx, eventId, input) {
const wanted = normalizeSessionsInput(input);
const existing = await tx.eventSession.findMany({ where: { eventId }, include: { _count: { select: { usages: true } } } });
const byId = new Map(existing.map(s => [s.id, s]));
const changes = { added: 0, moved: 0, cancelled: 0, restored: 0, removed: 0 };
const keep = new Set(wanted.filter(w => w.id && byId.has(w.id)).map(w => w.id));
for (const s of existing) {
if (keep.has(s.id)) continue;
if (s._count.usages > 0) {
throw sessionError(`"${s.label || dayKey(s.startsAt)}" already has check-ins, so it can't be removed. Cancel it instead.`);
}
await tx.eventSession.delete({ where: { id: s.id } });
changes.removed += 1;
}
for (let i = 0; i < wanted.length; i++) {
const w = wanted[i];
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
const old = w.id ? byId.get(w.id) : null;
if (old) {
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
if (+old.startsAt !== +w.startsAt || +old.endsAt !== +w.endsAt) changes.moved += 1;
if (!old.cancelledAt && cancelledAt) changes.cancelled += 1;
if (old.cancelledAt && !cancelledAt) changes.restored += 1;
await tx.eventSession.update({ where: { id: old.id }, data: { ...data, cancelledAt } });
} else {
await tx.eventSession.create({ data: { ...data, eventId, cancelledAt: w.cancelled ? new Date() : null } });
changes.added += 1;
}
}
const sessions = await tx.eventSession.findMany({ where: { eventId }, orderBy: { startsAt: 'asc' } });
const bounds = scheduleBounds(sessions);
await tx.event.update({ where: { id: eventId }, data: { startDate: bounds.startDate, endDate: bounds.endDate } });
return { sessions, changes };
}
const SCHEDULE_SELECT = {
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
sessions: { orderBy: { startsAt: 'asc' } },
};
// Late pricing is looked up while pricing every option line, so keep each event's
// schedule for a few seconds rather than querying it per line.
const scheduleCache = new Map();
const SCHEDULE_TTL_MS = 5000;
async function eventSchedule(eventId) {
const hit = scheduleCache.get(eventId);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await prisma.event.findUnique({ where: { id: eventId }, select: SCHEDULE_SELECT });
scheduleCache.set(eventId, { value, expires: Date.now() + SCHEDULE_TTL_MS });
if (scheduleCache.size > 500) scheduleCache.delete(scheduleCache.keys().next().value);
return value;
}
function forgetSchedule(eventId) {
scheduleCache.delete(eventId);
}
/** Label for a session in messages: its own label, else "Session N" by position. */
function sessionLabel(session, sessions) {
if (session.label) return session.label;
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
return `Session ${idx >= 0 ? idx + 1 : ''}`.trim();
}
/**
* Which session a scan on a multi-session event counts for. `requestedId` is the session
* the scanner picked (optional — defaults to today's). Returns { session, error } with
* session null and an error message when check-in isn't possible now.
*/
function scanSession(event, sessions, requestedId = null, now = new Date()) {
const window = event.checkInWindow || 'whole_day';
if (requestedId) {
const session = (sessions || []).find(s => s.id === requestedId);
if (!session) return { session: null, error: 'That session isn\'t part of this event' };
if (session.cancelledAt) return { session: null, error: `${sessionLabel(session, sessions)} was cancelled` };
if (!sessionAcceptsCheckIn(session, now, window)) {
return { session: null, error: `Check-in for ${sessionLabel(session, sessions)} is only open ${window === 'session_only' ? 'from 2 hours before it starts until it ends' : 'on the day of the session'}` };
}
return { session, error: null };
}
const session = currentSession(sessions, now, window);
if (!session) {
const next = activeSessions(sessions).find(s => new Date(s.startsAt) > new Date(now));
return { session: null, error: next ? `No session today — the next one is ${sessionLabel(next, sessions)} on ${dayKey(next.startsAt)}` : 'All sessions of this event are over' };
}
return { session, error: null };
}
/**
* How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec"
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
* Returns null for single-date events (callers keep their own date format).
*/
function ticketDateText(event, style = 'short', session = null, sessions = null) {
if (event?.scheduleType !== 'sessions') return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
// A picked-session ticket is for its own session only.
if (session) {
const label = sessionLabel(session, event.sessions);
return style === 'long'
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
}
if (sessions && sessions.length > 1 && style === 'long') {
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
}
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
if (style === 'long') {
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
}
const short = { day: '2-digit', month: 'short' };
return active.length === 1
? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' })
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
}
/**
* scanSession for a ticket: a picked-session ticket only checks in on its own session
* (whatever the scanner has selected).
*/
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
const label = sessionLabel(pinned, sessions);
if (requestedId && requestedId !== pinnedSessionId) {
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
}
const found = scanSession(event, sessions, pinnedSessionId, now);
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
}
/**
* Groups an event's tickets into people for the register and certificates: a
* picked-session booking has one ticket per session, so its tickets (same registration,
* option and variant) form one group. Every other ticket is its own group.
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId }.
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
*/
function groupTickets(tickets) {
const groups = new Map();
for (const t of tickets) {
const ro = t.registrationOption || {};
if (!ro.sessionId) {
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
continue;
}
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
g.tickets.push(t);
g.sessionIds.push(ro.sessionId);
groups.set(key, g);
}
return [...groups.values()];
}
/** Places already checked in for one session, from a ticket's usages. */
function redeemedForSession(usages, sessionId) {
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
}
/** Applies late pricing to a resolved option price ({ price, tierId }). */
async function withLatePrice(option, resolved) {
// Picked sessions are priced per session, so late joining doesn't change them.
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
const event = await eventSchedule(option.eventId);
if (!event || event.scheduleType !== 'sessions') return resolved;
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
return price === resolved.price ? resolved : { ...resolved, price };
}
/**
* Check-ins per ticket per session for an event: Map ticketId → Map sessionId → qty.
* Cancelled sessions are included; callers drop them when counting.
*/
async function attendanceBySession(eventId) {
const usages = await prisma.ticketUsage.findMany({
where: { ticket: { eventId }, sessionId: { not: null } },
select: { ticketId: true, sessionId: true, quantityRedeemed: true },
});
const out = new Map();
for (const u of usages) {
if (!out.has(u.ticketId)) out.set(u.ticketId, new Map());
const m = out.get(u.ticketId);
m.set(u.sessionId, (m.get(u.sessionId) || 0) + (u.quantityRedeemed || 1));
}
return out;
}
module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+6 -4
View File
@@ -3,18 +3,20 @@
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
* option the limit is per session (pass sessionId).
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
* variantId given → that variant only; sessionId given → that session's picked places only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
+2 -2
View File
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
}
} catch (_) { /* form models unavailable — don't block */ }
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
const byOption = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
if (!byOption.has(key)) byOption.set(key, []);
byOption.get(key).push(ro);
}
+54
View File
@@ -0,0 +1,54 @@
const { JOBS, isEnabled, cronFor, nextRun, isNoop } = require('../src/jobs');
const job = (name) => JOBS.find(j => j.name === name);
describe('job registry', () => {
const saved = { ...process.env };
afterEach(() => { process.env = { ...saved }; });
test('every job has a valid cron schedule and a unique name', () => {
const names = new Set();
for (const j of JOBS) {
expect(names.has(j.name)).toBe(false);
names.add(j.name);
expect(() => nextRun(j.cron)).not.toThrow();
}
});
test('a job is on unless its own flag or JOBS_ENABLED is false', () => {
delete process.env.WAITLIST_JOB_ENABLED;
delete process.env.JOBS_ENABLED;
expect(isEnabled(job('waitlist'))).toBe(true);
process.env.WAITLIST_JOB_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
process.env.WAITLIST_JOB_ENABLED = 'true';
process.env.JOBS_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
expect(isEnabled(job('temp-cleanup'))).toBe(false);
});
test('JOB_SCHEDULE_<NAME> overrides the schedule; an invalid one is ignored', () => {
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = '30 5 * * *';
expect(cronFor(job('analytics-rollup'))).toBe('30 5 * * *');
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = 'every tuesday';
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(cronFor(job('analytics-rollup'))).toBe(job('analytics-rollup').cron);
warn.mockRestore();
});
test('daily jobs fire at local (Johannesburg) time', () => {
// 07:00 SAST = 05:00 UTC
const next = nextRun('0 7 * * *', new Date('2026-11-04T06:00:00Z'));
expect(next.toISOString()).toBe('2026-11-05T05:00:00.000Z');
expect(nextRun('0 7 * * *', new Date('2026-11-04T04:00:00Z')).toISOString()).toBe('2026-11-04T05:00:00.000Z');
});
test('isNoop: quiet when nothing happened', () => {
expect(isNoop({ expired: 0, offers: 0 })).toBe(true);
expect(isNoop({ reminders: { sessions: 0, sent: 0 }, certificates: { issued: 0, sent: 0 } })).toBe(true);
expect(isNoop({ deleted: 0, errors: 0 })).toBe(true);
expect(isNoop(null)).toBe(true);
expect(isNoop({ sent: 3 })).toBe(false);
expect(isNoop({ file: 'backup.sql.gz' })).toBe(false);
});
});
+334
View File
@@ -0,0 +1,334 @@
const {
dayKey,
activeSessions,
scheduleBounds,
normalizeSessionsInput,
sessionAcceptsCheckIn,
currentSession,
registrationClosesAt,
lateInfo,
latePrice,
completionMet,
normalizeReminders,
normalizeCertificateConfig,
scanSession,
redeemedForSession,
sessionLabel,
isPickOption,
expandPickLines,
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
const wed = (day, h = 16, m = 30) => new Date(Date.UTC(2026, 10, day, h, m)); // November 2026
const session = (id, day, extra = {}) => ({ id, startsAt: wed(day), endsAt: wed(day, 18, 30), cancelledAt: null, ...extra });
// Five Wednesdays: 4, 11, 18, 25 Nov and 2 Dec (as day 32 → Date.UTC rolls over).
const COURSE = [session('s1', 4), session('s2', 11), session('s3', 18), session('s4', 25), session('s5', 32)];
describe('dayKey', () => {
test('uses the church timezone, not UTC', () => {
expect(dayKey(new Date('2026-11-04T23:30:00Z'))).toBe('2026-11-05'); // 01:30 local next day
expect(dayKey(new Date('2026-11-04T21:59:00Z'))).toBe('2026-11-04');
});
});
describe('activeSessions / scheduleBounds', () => {
test('drops cancelled sessions and sorts by start', () => {
const list = [session('b', 11), session('a', 4), session('c', 18, { cancelledAt: new Date() })];
expect(activeSessions(list).map(s => s.id)).toEqual(['a', 'b']);
});
test('bounds run from the first start to the last end', () => {
const b = scheduleBounds(COURSE);
expect(b.startDate).toEqual(wed(4));
expect(b.endDate).toEqual(wed(32, 18, 30));
});
test('a cancelled last session moves the end date back', () => {
const list = [...COURSE.slice(0, 4), { ...COURSE[4], cancelledAt: new Date() }];
expect(scheduleBounds(list).endDate).toEqual(wed(25, 18, 30));
});
test('no active sessions → null', () => {
expect(scheduleBounds([session('a', 4, { cancelledAt: new Date() })])).toBeNull();
});
});
describe('normalizeSessionsInput', () => {
test('sorts sessions and trims labels', () => {
const out = normalizeSessionsInput([
{ startsAt: wed(11), endsAt: wed(11, 18), label: ' Week 2 ' },
{ startsAt: wed(4), endsAt: wed(4, 18) },
]);
expect(out.map(s => s.label)).toEqual([null, 'Week 2']);
});
test('rejects a session that ends before it starts', () => {
expect(() => normalizeSessionsInput([{ startsAt: wed(4, 18), endsAt: wed(4, 16) }])).toThrow(/must end after/);
});
test('rejects missing dates', () => {
expect(() => normalizeSessionsInput([{ startsAt: 'nope', endsAt: wed(4) }])).toThrow(/start and end/);
});
test('needs at least one session that is not cancelled', () => {
expect(() => normalizeSessionsInput([{ startsAt: wed(4), endsAt: wed(4, 18), cancelled: true }])).toThrow(/at least one/);
expect(() => normalizeSessionsInput([])).toThrow(/at least one/);
});
});
describe('check-in window', () => {
const s = session('s1', 4); // 18:30–20:30 local on 4 Nov
test('whole day accepts any time on the session date (local)', () => {
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T05:00:00Z'), 'whole_day')).toBe(true); // 07:00 local
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T21:30:00Z'), 'whole_day')).toBe(true); // 23:30 local
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T22:30:00Z'), 'whole_day')).toBe(false); // 00:30 next day
});
test('session only accepts from 2 h before start until the end', () => {
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:29:00Z'), 'session_only')).toBe(false);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:30:00Z'), 'session_only')).toBe(true);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:30:00Z'), 'session_only')).toBe(true);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:31:00Z'), 'session_only')).toBe(false);
});
test('cancelled sessions never accept check-ins', () => {
expect(sessionAcceptsCheckIn({ ...s, cancelledAt: new Date() }, wed(4), 'whole_day')).toBe(false);
});
});
describe('currentSession', () => {
test('finds today\'s session', () => {
expect(currentSession(COURSE, new Date('2026-11-11T08:00:00Z')).id).toBe('s2');
});
test('null on a day without a session', () => {
expect(currentSession(COURSE, new Date('2026-11-12T08:00:00Z'))).toBeNull();
});
test('with two sessions in a day, picks the running one, else the next, else the last', () => {
const day = [
{ id: 'am', startsAt: new Date('2026-11-07T07:00:00Z'), endsAt: new Date('2026-11-07T10:00:00Z'), cancelledAt: null },
{ id: 'pm', startsAt: new Date('2026-11-07T14:00:00Z'), endsAt: new Date('2026-11-07T17:00:00Z'), cancelledAt: null },
];
expect(currentSession(day, new Date('2026-11-07T08:00:00Z')).id).toBe('am');
expect(currentSession(day, new Date('2026-11-07T11:00:00Z')).id).toBe('pm'); // between: next one
expect(currentSession(day, new Date('2026-11-07T13:00:00Z')).id).toBe('pm'); // within 2 h of pm
expect(currentSession(day, new Date('2026-11-07T19:00:00Z')).id).toBe('pm'); // after both: last
expect(currentSession(day, new Date('2026-11-07T04:00:00Z')).id).toBe('am'); // before both: next
});
});
describe('registrationClosesAt', () => {
const base = { scheduleType: 'sessions', endDate: wed(32, 18, 30), registrationDeadline: null, lateJoinEnabled: false, lateJoinUntilSessionId: null };
test('single-date events close at the end date', () => {
expect(registrationClosesAt({ ...base, scheduleType: 'single' }, [])).toEqual(wed(32, 18, 30));
});
test('without late joining, closes when the first session starts', () => {
expect(registrationClosesAt(base, COURSE)).toEqual(wed(4));
});
test('with late joining, closes when the chosen session starts', () => {
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, COURSE)).toEqual(wed(18));
});
test('an earlier registration deadline wins', () => {
const deadline = new Date('2026-11-01T00:00:00Z');
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3', registrationDeadline: deadline }, COURSE)).toEqual(deadline);
});
test('a cancelled "until" session falls back to the first session', () => {
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, list)).toEqual(wed(4));
});
});
describe('late pricing', () => {
const event = { scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', lateFixedPrice: null };
test('not active before the first session starts', () => {
expect(lateInfo(event, COURSE, wed(3)).active).toBe(false);
});
test('pro-rata on sessions not yet started, rounded to the nearest R10', () => {
const info = lateInfo(event, COURSE, wed(5)); // session 1 done → 4 of 5 left
expect(info).toMatchObject({ active: true, sessionsLeft: 4, totalSessions: 5 });
expect(latePrice(300, info)).toBe(240);
expect(latePrice(229, info)).toBe(180); // 183.2 → 180
expect(latePrice(232, info)).toBe(190); // 185.6 → 190
});
test('pro-rata never goes below R10 or above the normal price', () => {
const info = lateInfo(event, COURSE, wed(26)); // 1 of 5 left
expect(latePrice(15, info)).toBe(10); // 3 → 0 → at least R10
expect(latePrice(8, info)).toBe(8); // under R10 already: unchanged
expect(latePrice(0, info)).toBe(0);
});
test('a session in progress counts as started', () => {
const info = lateInfo(event, COURSE, new Date(wed(11).getTime() + 60_000));
expect(info.sessionsLeft).toBe(3);
});
test('fixed late price only ever lowers the price', () => {
const info = lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: 150 }, COURSE, wed(12));
expect(latePrice(300, info)).toBe(150);
expect(latePrice(100, info)).toBe(100);
});
test('full price, late joining off, or a fixed price without an amount → no change', () => {
expect(lateInfo({ ...event, latePricing: 'full' }, COURSE, wed(12)).active).toBe(false);
expect(lateInfo({ ...event, lateJoinEnabled: false }, COURSE, wed(12)).active).toBe(false);
expect(lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: null }, COURSE, wed(12)).active).toBe(false);
expect(latePrice(300, { active: false })).toBe(300);
});
});
describe('completionMet', () => {
test('all sessions (default)', () => {
expect(completionMet(undefined, 5, 5)).toBe(true);
expect(completionMet({ type: 'all' }, 4, 5)).toBe(false);
});
test('at least N sessions, capped at the total', () => {
expect(completionMet({ type: 'count', value: 4 }, 4, 5)).toBe(true);
expect(completionMet({ type: 'count', value: 4 }, 3, 5)).toBe(false);
expect(completionMet({ type: 'count', value: 10 }, 3, 3)).toBe(true);
});
test('at least N percent', () => {
expect(completionMet({ type: 'percent', value: 80 }, 4, 5)).toBe(true);
expect(completionMet({ type: 'percent', value: 80 }, 3, 5)).toBe(false);
expect(completionMet({ type: 'percent', value: 66 }, 2, 3)).toBe(true);
});
test('nobody completes without attending', () => {
expect(completionMet({ type: 'count', value: 1 }, 0, 5)).toBe(false);
expect(completionMet(undefined, 0, 0)).toBe(false);
});
});
describe('settings cleaning', () => {
test('reminders: off unless an offset and a channel are chosen', () => {
expect(normalizeReminders(null)).toBeNull();
expect(normalizeReminders({ offsets: [180], channels: [] })).toBeNull();
expect(normalizeReminders({ offsets: [180, 999, 1440], channels: ['email', 'sms'] })).toEqual({ offsets: [1440, 180], channels: ['email'] });
});
test('certificates: unknown template falls back to classic, rule is tidied', () => {
const c = normalizeCertificateConfig({ template: 'fancy', rule: { type: 'count', value: '3' } });
expect(c.template).toBe('classic');
expect(c.rule).toEqual({ type: 'count', value: 3 });
expect(c.title).toBe('Certificate of Completion');
expect(normalizeCertificateConfig({ rule: { type: 'all', value: 4 } }).rule).toEqual({ type: 'all', value: null });
});
});
describe('scanSession', () => {
const event = { checkInWindow: 'whole_day' };
test("defaults to today's session", () => {
expect(scanSession(event, COURSE, null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
});
test('no session today: says when the next one is', () => {
const r = scanSession(event, COURSE, null, new Date('2026-11-12T09:00:00Z'));
expect(r.session).toBeNull();
expect(r.error).toMatch(/next one is Session 3 on 2026-11-18/);
});
test('after the last session: all over', () => {
expect(scanSession(event, COURSE, null, new Date('2026-12-10T09:00:00Z')).error).toMatch(/over/);
});
test('a picked session must be open now', () => {
expect(scanSession(event, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(scanSession(event, COURSE, 's4', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only open on the day/);
expect(scanSession({ checkInWindow: 'session_only' }, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/2 hours before/);
});
test('a cancelled or unknown session is refused', () => {
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
expect(scanSession(event, list, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/cancelled/);
expect(scanSession(event, COURSE, 'nope').error).toMatch(/isn't part/);
});
});
describe('redeemedForSession / sessionLabel', () => {
test("counts only that session's check-ins", () => {
const usages = [{ sessionId: 's1', quantityRedeemed: 2 }, { sessionId: 's2', quantityRedeemed: 1 }, { sessionId: 's1' }];
expect(redeemedForSession(usages, 's1')).toBe(3);
expect(redeemedForSession(usages, 's3')).toBe(0);
});
test('uses the label, else the position among active sessions', () => {
expect(sessionLabel({ id: 'x', label: 'Opening night' }, COURSE)).toBe('Opening night');
expect(sessionLabel(COURSE[2], COURSE)).toBe('Session 3');
const list = [{ ...COURSE[0], cancelledAt: new Date() }, ...COURSE.slice(1)];
expect(sessionLabel(list[2], list)).toBe('Session 2');
});
});
describe('pick sessions', () => {
const pickEvent = {
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
lateJoinEnabled: false, sessions: COURSE,
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
});
test('a pick line becomes one line per session; others stay as they are', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null },
]);
});
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
});
test('staff can still book a session that is in progress', () => {
const during = new Date(wed(4).getTime() + 30 * 60000);
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
});
test('registration stays open until the last session; whole-course tickets close at the first', () => {
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
});
test('a picked ticket only checks in on its own session', () => {
const event = { checkInWindow: 'whole_day' };
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
});
test('groupTickets puts one booking\'s picked tickets together', () => {
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
expect(groups[1].sessionIds).toBeNull();
});
});
+506
View File
@@ -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.
+90
View File
@@ -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.
+774
View File
@@ -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?
+48
View File
@@ -0,0 +1,48 @@
import { test as base, expect, type Page } from "@playwright/test";
import fs from "fs";
import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
export const API = "http://localhost:5100";
// Written by e2e/support/prepare-db.js when the e2e backend starts, so read lazily.
let cached: Seed | null = null;
export function seed(): Seed {
if (!cached) cached = JSON.parse(fs.readFileSync(path.join(__dirname, ".data", "seed.json"), "utf8")) as Seed;
return cached;
}
/** Signs the page in as a seeded user (the token goes where the app keeps it). */
export async function loginAs(page: Page, role: Role) {
await page.addInitScript((token) => {
localStorage.setItem("hope_events_token", token);
}, seed().users[role].token);
}
export const test = base.extend({
page: async ({ page }, use) => {
// Skip the cookie notice so it never covers what the tests click.
await page.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
await use(page);
},
});
/** Calls the e2e API as a seeded user. */
export async function api<T = unknown>(page: Page, role: Role, method: string, url: string, body?: unknown): Promise<T> {
const res = await page.request.fetch(`${API}${url}`, {
method,
headers: { Authorization: `Bearer ${seed().users[role].token}`, "Content-Type": "application/json" },
data: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok()) throw new Error(`${method} ${url} → ${res.status()}: ${text}`);
return (text ? JSON.parse(text) : null) as T;
}
export { expect };
+26
View File
@@ -0,0 +1,26 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
test.describe("pick sessions", () => {
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.dropIn}`);
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await expect(picker.getByText("Full")).toBeVisible();
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
await picker.getByRole("checkbox").nth(1).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R100.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect, seed } from "./fixtures";
test.describe("public pages", () => {
test("events list shows events, with the multi-day summary", async ({ page }) => {
await page.goto("/events");
await expect(page.getByRole("heading", { name: "All Events" })).toBeVisible();
await expect(page.getByText("E2E Free Evening")).toBeVisible();
const course = page.locator("div.border.rounded-xl", { hasText: "E2E Alpha Course" });
await expect(course.getByText(/5 sessions · /)).toBeVisible();
});
test("a multi-day event page shows the schedule and the late-joiner price", async ({ page }) => {
await page.goto(`/events/${seed().events.course}`);
await expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible();
const schedule = page.locator("div.rounded-xl", { has: page.getByRole("heading", { name: "Schedule" }) });
await expect(schedule.getByText("Week 1")).toBeVisible();
await expect(schedule.getByText("Week 5")).toBeVisible();
await expect(schedule.getByText("Next")).toBeVisible();
// Week 2 is in progress, so 3 of 5 are still to come: R300 × 3/5 = R180.
await expect(page.getByText(/3 of 5 sessions are still to come/).filter({ visible: true }).first()).toBeVisible();
await expect(page.getByText("R180.00").filter({ visible: true }).first()).toBeVisible();
});
test("an unknown certificate code is reported as not found", async ({ page }) => {
await page.goto("/certificates/verify/ABCDE-12345");
await expect(page.getByRole("heading", { name: "Certificate not found" })).toBeVisible();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; status: string; eventId: string };
type Ticket = { id: string; qrCode: string; eventId: string };
type CashByUser = { rows: { userId: string | null; cash: { total: number; count: number } }[] };
test.describe("registration → payment → ticket → check-in → cashup", () => {
test("a free event: register and get a ticket straight away", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.free}`);
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const tickets = await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets");
expect(tickets.some((t) => t.eventId === seed().events.free)).toBe(true);
});
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
await page.getByRole("button", { name: "+" }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const reg = regs.find((r) => r.eventId === seed().events.paid);
expect(reg?.status).not.toBe("paid");
expect((await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).some((t) => t.eventId === seed().events.paid)).toBe(false);
// A supervisor records the cash payment at the desk.
await api(page, "supervisor", "POST", "/api/payments", { amount: 150, method: "cash", registrationId: reg!.id, eventId: seed().events.paid, userId: seed().users.user.id });
const ticket = (await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).find((t) => t.eventId === seed().events.paid);
expect(ticket).toBeTruthy();
// My Events shows it.
await page.goto("/dashboard/user");
await expect(page.getByText("E2E Paid Conference").first()).toBeVisible();
// Staff check it in; a second scan is refused.
await api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 });
await expect(api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 })).rejects.toThrow(/403.*fully used/);
// Cashup: the R150 cash is on the supervisor who took it.
const cash = await api<CashByUser>(page, "supervisor", "GET", `/api/cashups/event/${seed().events.paid}/cash-by-user`);
const mine = cash.rows.find((r) => r.userId === seed().users.supervisor.id);
expect(mine?.cash.total).toBe(150);
expect(mine?.cash.count).toBe(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
test.describe("multi-day events", () => {
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
const { course, events } = seed();
// Scanning today: Week 2.
const scan = await api<{ session: { label: string } }>(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 });
expect(scan.session.label).toBe("Week 2");
await expect(api(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 })).rejects.toThrow(/Already checked in for Week 2/);
// The register shows weeks 1 and 2 ticked.
await loginAs(page, "supervisor");
await page.goto(`/dashboard/supervisor/attendance?eventId=${events.course}`);
const row = page.locator("tr", { hasText: "Thandi Test" });
await expect(row.getByText("2/5")).toBeVisible();
// Tick week 3 by hand (e.g. let in without a scan), then untick it.
const week3 = row.locator("td").nth(3).getByRole("button");
await week3.click();
await expect(row.getByText("3/5")).toBeVisible();
await week3.click();
await expect(row.getByText("2/5")).toBeVisible();
// Two sessions meets the rule (at least 2), so a certificate can be issued now.
await page.getByRole("button", { name: "Issue now" }).click();
await expect(page.getByText("1 certificate issued.")).toBeVisible();
const code = (await row.locator("span.font-mono").innerText()).trim();
expect(code).toMatch(/^[A-Z0-9]{5}-[A-Z0-9]{5}$/);
// Anyone can check it.
await page.goto(`/certificates/verify/${code}`);
await expect(page.getByRole("heading", { name: "Valid certificate" })).toBeVisible();
await expect(page.getByText("Thandi Test")).toBeVisible();
});
test("the attendee sees their certificate on My Events", async ({ page }) => {
await loginAs(page, "user");
await page.goto("/dashboard/user");
await expect(page.getByRole("heading", { name: "My Certificates" })).toBeVisible();
await expect(page.getByText("E2E Alpha Course").first()).toBeVisible();
});
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/ticket-scanning");
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
test.describe("supervisor tools", () => {
test("create a weekly multi-session event in the wizard", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/events");
await page.getByRole("button", { name: "+ Add Event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
await page.getByPlaceholder("Event title").fill("E2E Wizard Course");
await page.getByRole("button", { name: "Multiple sessions" }).click();
// The repeat pattern defaults to weekly, 18:30–20:30, 5 sessions from today.
await page.getByRole("button", { name: "Create sessions" }).click();
await expect(page.getByText(/^5 sessions — edit any date/)).toBeVisible();
await page.locator('input[type="number"][placeholder="0"]').first().fill("250");
// Through the remaining steps to Create Event.
const create = page.getByRole("button", { name: "Create Event" });
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
await page.getByRole("button", { name: "Next →" }).click();
}
await create.click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeHidden();
const row = page.locator("li", { hasText: "E2E Wizard Course" });
await expect(row.getByText(/5 sessions · /)).toBeVisible();
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all");
const created = all.find((e) => e.title === "E2E Wizard Course");
expect(created?.scheduleType).toBe("sessions");
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
});
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
test("admin: background jobs page", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/settings?tab=jobs");
await expect(page.getByRole("heading", { name: "Background jobs" })).toBeVisible();
// The e2e backend runs with JOBS_ENABLED=false.
await expect(page.getByText(/All background jobs are switched off/)).toBeVisible();
await expect(page.getByText("Session reminders and certificates")).toBeVisible();
});
});
+16
View File
@@ -0,0 +1,16 @@
// The e2e database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added
// to the database name — always separate from the database you develop against.
const path = require('path');
function e2eDatabaseUrl() {
if (process.env.E2E_DATABASE_URL) return process.env.E2E_DATABASE_URL;
const backend = path.join(__dirname, '..', '..', 'backend');
require(require.resolve('dotenv', { paths: [backend] })).config({ path: path.join(backend, '.env'), quiet: true });
if (!process.env.DATABASE_URL) throw new Error('Set E2E_DATABASE_URL (or DATABASE_URL in backend/.env) for the e2e tests.');
const u = new URL(process.env.DATABASE_URL);
const name = u.pathname.replace(/^\//, '');
u.pathname = `/${name.endsWith('_e2e') ? name : `${name}_e2e`}`;
return u.toString();
}
module.exports = { e2eDatabaseUrl };
+160
View File
@@ -0,0 +1,160 @@
/**
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
* login tokens the tests use.
*
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
* database name. It is always a separate database — never the one you develop against.
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const root = path.join(__dirname, '..', '..');
const backend = path.join(root, 'backend');
const req = (m) => require(require.resolve(m, { paths: [backend] }));
req('dotenv').config({ path: path.join(backend, '.env') });
const { e2eDatabaseUrl } = require('./db-url');
async function recreateDatabase(url) {
const { Client } = req('pg');
const target = new URL(url);
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
const admin = new URL(url);
admin.pathname = '/postgres';
admin.search = '';
const client = new Client({ connectionString: admin.toString() });
await client.connect();
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await client.query(`CREATE DATABASE "${dbName}"`);
await client.end();
}
async function seed(url) {
process.env.DATABASE_URL = url;
const prisma = require(path.join(backend, 'src/config/db'));
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
await prisma.appSetting.createMany({
data: [
{ key: 'setup_complete', value: 'true' },
{ key: 'org_name', value: 'E2E Church' },
{ key: 'org_address', value: '1 Test Street, Pretoria' },
],
});
const password = await hashPassword('E2e-password-123');
const mk = (role, name, email, phoneNumber) => prisma.user.create({
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() },
});
const users = {
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
};
const D = 86400000;
const now = Date.now();
const at = (days, hourUtc = 16, minute = 30) => {
const d = new Date(now + days * D);
d.setUTCHours(hourUtc, minute, 0, 0);
return d;
};
const free = await prisma.event.create({
data: {
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
const paid = await prisma.event.create({
data: {
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
},
});
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
const todayStart = new Date(now - 30 * 60 * 1000);
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
const startsAt = new Date(todayStart.getTime() + days * D);
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
});
const course = await prisma.event.create({
data: {
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true,
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
sessions: { create: sessions },
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
// A drop-in class: three upcoming classes, booked per class (the first is full).
const classes = [1, 8, 15].map((days, i) => {
const startsAt = at(days, 16, 0);
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
});
const dropIn = await prisma.event.create({
data: {
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
sessions: { create: classes },
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
// Someone else already has the only place in class 1.
await prisma.registration.create({
data: {
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
},
});
// Thandi is booked on the course and was checked in for week 1.
const reg = await prisma.registration.create({
data: {
userId: users.user.id, eventId: course.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
},
});
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
await generateTicketsForRegistration(reg.id);
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
};
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
await prisma.$disconnect?.();
}
(async () => {
const url = e2eDatabaseUrl();
await recreateDatabase(url);
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
await seed(url);
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
process.exit(0);
})().catch((e) => {
console.error('[e2e] Preparing the database failed:', e);
process.exit(1);
});
+3
View File
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
// The end-to-end tests run their own dev server; a separate build folder keeps it
// from clashing with one already running.
distDir: process.env.NEXT_DIST_DIR || ".next",
images: {
domains: ['images.unsplash.com'],
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.30.1",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.30.1",
"version": "1.35.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.30.1",
"version": "1.35.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -0,0 +1,61 @@
import { Metadata } from "next";
import { BadgeCheck, ShieldX } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Verify a certificate",
robots: { index: false },
};
type Verification = { valid: boolean; revoked?: boolean; name?: string; event?: string; completedOn?: string; issuedAt?: string };
// Public check for the QR code / code printed on a completion certificate. Shows only the
// name, event and dates — nothing else about the person.
export default async function VerifyCertificatePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
let result: Verification = { valid: false };
try {
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
} catch {
result = { valid: false };
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-12 px-4 max-w-lg mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm text-center">
{result.valid ? (
<>
<BadgeCheck className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">Valid certificate</h1>
<p className="text-gray-700 mt-3"><span className="font-semibold">{result.name}</span> completed</p>
<p className="text-lg font-medium text-brand-700 mt-1">{result.event}</p>
<p className="text-sm text-gray-500 mt-3">
{result.completedOn && <>Completed {formatDate(new Date(result.completedOn))} · </>}
{result.issuedAt && <>Issued {formatDate(new Date(result.issuedAt))}</>}
</p>
</>
) : (
<>
<ShieldX className="w-12 h-12 text-red-500 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">{result.revoked ? "This certificate was withdrawn" : "Certificate not found"}</h1>
<p className="text-sm text-gray-500 mt-3">
{result.revoked
? "The organiser has withdrawn this certificate, so it's no longer valid."
: "Check the code on the certificate and try again. Codes look like K7M2Q-9XRTB."}
</p>
</>
)}
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
</div>
</main>
<Footer />
</div>
);
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -27,11 +28,18 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
];
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
/** The code from Search Console's verification meta tag; accepts the whole tag or just the code. */
function googleVerificationCode(input: string): string {
const m = input.match(/content\s*=\s*["']([^"']*)["']/i);
return (m ? m[1] : input).trim();
}
function Field({
label, hint, required, children,
}: {
@@ -104,6 +112,7 @@ function SiteSettingsPageInner() {
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
const [googleVerification, setGoogleVerification] = useState("");
// ── Branding ──
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
@@ -153,6 +162,7 @@ function SiteSettingsPageInner() {
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setGoogleVerification(s.google_site_verification || "");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
@@ -209,6 +219,7 @@ function SiteSettingsPageInner() {
org_phone: orgPhone.trim(),
org_address: orgAddress.trim(),
app_base_url: appBaseUrl.trim(),
google_site_verification: googleVerificationCode(googleVerification),
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
@@ -432,6 +443,10 @@ function SiteSettingsPageInner() {
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<Field label="Google Search Console verification" hint="In Search Console, add this site as a URL-prefix property and choose the HTML tag method. Paste the tag (or just its content code) here and save, then click Verify in Search Console. Leave it set to stay verified.">
<input className={inputCls} placeholder='<meta name="google-site-verification" content="..." />'
value={googleVerification} onChange={e => setGoogleVerification(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
@@ -692,6 +707,7 @@ function SiteSettingsPageInner() {
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
</div>
{activeTab === "branding" && (
+2 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
@@ -15,6 +15,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
] as const;
export default function StaffDashboardPage() {
@@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan";
import { errorMessage } from "@/lib/utils";
import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
@@ -30,6 +31,10 @@ type ScanTicket = {
attendee?: { name: string } | null;
user?: { name?: string; email?: string } | null;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
// Multi-day events: the session this scan counts for, and what's left for it.
remaining?: number;
session?: { id: string; label: string } | null;
sessionError?: string | null;
};
export default function TicketScanningPage() {
@@ -73,6 +78,8 @@ export default function TicketScanningPage() {
const [sections, setSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false);
// Multi-day events: "auto" = today's session (the server picks it).
const [selectedSessionId, setSelectedSessionId] = useState<string>("auto");
// All events from API (including past); frontend filters to 12h window or all past
useEffect(() => {
@@ -100,6 +107,11 @@ export default function TicketScanningPage() {
});
}, [allEvents, includePastEvents]);
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
useEffect(() => { setSelectedSessionId("auto"); }, [selectedEventId]);
const sessionParam = selectedEventId !== "all" && selectedSessionId !== "auto" ? selectedSessionId : null;
useEffect(() => {
(async () => {
if (!token || !selectedEventId || selectedEventId === "all") {
@@ -136,7 +148,7 @@ export default function TicketScanningPage() {
useEffect(() => {
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
scannerRef.current?.stop();
}, [selectedEventId, selectedSectionId]);
}, [selectedEventId, selectedSectionId, selectedSessionId]);
async function handleResult(text: string) {
setError(null);
@@ -177,7 +189,7 @@ export default function TicketScanningPage() {
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: ScanTicket;
try {
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
} catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return;
@@ -208,13 +220,19 @@ export default function TicketScanningPage() {
}
}
// Compute remaining qty
// Multi-day events: no session open for check-in now (wrong day, cancelled…).
if (preview?.sessionError) {
setErrorModal(preview.sessionError);
return;
}
// Compute remaining qty (the server counts it per session for multi-day events)
const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed;
const remaining = typeof preview?.remaining === "number" ? preview.remaining : ticketQty - totalRedeemed;
if (remaining <= 0) {
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
setAlreadyUsedModal({ message: preview?.session ? `Already checked in for ${preview.session.label}` : 'Ticket has already been fully used', ticket: preview });
return;
}
@@ -236,7 +254,7 @@ export default function TicketScanningPage() {
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name;
const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
const eventTitle = ticket?.event?.title || 'Event';
const eventTitle = `${ticket?.event?.title || 'Event'}${ticket?.session ? ` — ${ticket.session.label}` : ''}`;
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
const newRemaining = remaining - qty;
@@ -276,6 +294,7 @@ export default function TicketScanningPage() {
qty,
deviceId: offline.deviceId(),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
@@ -378,6 +397,23 @@ export default function TicketScanningPage() {
</div>
)}
{eventSessions.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Session</label>
<select
className="border rounded px-3 py-2 w-full sm:w-72 max-w-full"
value={selectedSessionId}
onChange={(e) => setSelectedSessionId(e.target.value)}
disabled={!!offline.lock}
>
<option value="auto">Today&apos;s session{currentSessionLabel(selectedEvent) ? ` (${currentSessionLabel(selectedEvent)})` : ""}</option>
{eventSessions.map(s => (
<option key={s.id} value={s.id}>{sessionLabel(s, selectedEvent?.sessions)} — {formatSessionWhen(s)}</option>
))}
</select>
</div>
)}
<OfflineScanPanel
offline={offline}
token={token}
@@ -467,6 +503,7 @@ export default function TicketScanningPage() {
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
{confirmModal.ticket?.session && <div><span className="font-medium">Session:</span> {confirmModal.ticket.session.label}</div>}
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
@@ -15,6 +16,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
import { isPickOption, todaysSession } from "@/lib/sessions";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -95,6 +97,7 @@ export default function AtTheDoorPage() {
const [eventId, setEventId] = useState("");
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
@@ -157,7 +160,11 @@ export default function AtTheDoorPage() {
authToken: token
});
setEventOptions(ev.options || ev.eventOptions || []);
// Per-session tickets ("pick sessions") are booked from Manual registration,
// which has the session picker.
const all = ev.options || ev.eventOptions || [];
setEventOptions(all.filter(o => !isPickOption(ev, o)));
setDoorHasPickOptions(all.some(o => isPickOption(ev, o)));
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
@@ -492,6 +499,11 @@ export default function AtTheDoorPage() {
{mode === "refund" && (
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
)}
{doorHasPickOptions && (
<div className="mt-4 p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
This event has per-session tickets. Book those from <Link className="underline font-medium" href="/dashboard/supervisor/manual">Manual registration</Link>, which lets you choose the sessions.
</div>
)}
<OptionsModal
open={showOptionsModal}
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
@@ -1045,10 +1057,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
);
}
// Multi-day events: check-in is per session, so only today's session's check-ins count.
const session = todaysSession(ticketsForEvent[0]?.event);
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = (t.usages || [])
.filter(u => !session || u.sessionId === session.id)
.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
@@ -1080,7 +1096,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
<div className="text-xl font-semibold mb-4">Check-In{session ? <span className="ml-2 text-sm font-normal text-gray-500">{session.label || "Today's session"}</span> : null}</div>
{/* ✅ User */}
<div className="mb-4">
@@ -0,0 +1,296 @@
"use client";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
const colDate = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
function AttendancePageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = ["admin", "supervisor", "staff"].includes(user?.role || "");
const canManage = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("eventId") || "");
const [reg, setReg] = useState<AttendanceRegister | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [showCancelled, setShowCancelled] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
// Multi-day events first (that's what the register is mostly for), newest first.
const list = (evs || []).filter(e => e.requiresRegistration !== false)
.sort((a, b) => Number(b.scheduleType === "sessions") - Number(a.scheduleType === "sessions") || new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setEvents(list);
if (!eventId && list.length) setEventId(list[0].id);
})
.catch(e => setError(errorMessage(e) || "Failed to load events"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, canView]);
const load = useCallback(async () => {
if (!token || !eventId) return;
setBusy(true);
try {
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Failed to load the register");
} finally {
setBusy(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const columns = useMemo(() => (reg?.columns || []).filter(c => showCancelled || !c.cancelled), [reg, showCancelled]);
const multi = reg?.event.scheduleType === "sessions";
// Sessions already held (for "missed the last two").
const heldIds = useMemo(() => (reg?.columns || []).filter(c => !c.cancelled && new Date(c.startsAt).getTime() < Date.now()).map(c => c.id), [reg]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return (reg?.rows || []).filter(r => {
if (q && ![r.name, r.bookedBy, r.email, r.phone].some(v => v?.toLowerCase().includes(q))) return false;
if (filter === "completed") return r.completed;
if (filter === "not_completed") return !r.completed;
if (filter === "missed_recent") {
const lastTwo = heldIds.slice(-2);
return lastTwo.length > 0 && lastTwo.every(id => !r.checkIns[id]);
}
return true;
});
}, [reg, query, filter, heldIds]);
const toggle = async (row: AttendanceRow, sessionId: string) => {
if (!canManage || !token) return;
const present = !row.checkIns[sessionId];
const key = `${row.ticketId}:${sessionId}`;
setPending(key);
try {
// Picked sessions have a ticket per session.
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
const col = reg?.columns.find(c => c.id === sessionId);
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't update attendance");
} finally {
setPending(null);
}
};
const post = async (path: string, body: object, done: (r: Record<string, number>) => string) => {
if (!token) return;
setBusy(true);
try {
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
setInfo(done(r));
await load();
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
};
const download = async (path: string, filename: string) => {
if (!token) return;
try {
saveBlob(await fetchApiFile(API_BASE, token, path), filename);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
};
const safeTitle = (reg?.event.title || "event").replace(/[^a-z0-9 -]/gi, "").slice(0, 60);
const certsOn = !!reg?.event.certificatesEnabled;
const issued = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt).length;
const unsent = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt && !r.certificate.emailedAt).length;
const completed = (reg?.rows || []).filter(r => r.completed).length;
return (
<div className="max-w-7xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Attendance</h1>
<p className="text-sm text-gray-500">Who came to each session, and completion certificates</p>
</div>
</div>
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view attendance.</div>}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input className="border rounded-lg pl-8 pr-3 py-2 text-sm w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as Filter)}>
<option value="all">Everyone</option>
<option value="completed">Completed</option>
<option value="not_completed">Not completed</option>
{multi && <option value="missed_recent">Missed the last two sessions</option>}
</select>
{multi && (reg?.columns || []).some(c => c.cancelled) && (
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showCancelled} onChange={e => setShowCancelled(e.target.checked)} /> Show cancelled sessions
</label>
)}
<div className="ml-auto flex flex-wrap gap-2">
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
</div>
</div>
{reg && (
<div className="flex flex-wrap gap-2 text-xs">
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700">{reg.rows.length} attendee{reg.rows.length === 1 ? "" : "s"}</span>
<span className="px-2 py-1 rounded bg-green-50 text-green-800">{completed} completed</span>
{certsOn && <span className="px-2 py-1 rounded bg-brand-50 text-brand-800">{issued} certificate{issued === 1 ? "" : "s"} issued</span>}
{!multi && <span className="px-2 py-1 rounded bg-gray-50 text-gray-500">Single-date event — one check-in column</span>}
</div>
)}
{reg && certsOn && canManage && (
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
<Award className="w-4 h-4 text-brand-600" />
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
</div>
)}
{busy && !reg && <p className="text-sm text-gray-500">Loading…</p>}
{reg && rows.length === 0 && <p className="text-sm text-gray-500 py-6 text-center">{reg.rows.length === 0 ? "No tickets for this event yet." : "Nobody matches."}</p>}
{reg && rows.length > 0 && (
<div className="overflow-x-auto border rounded-lg">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-600">
<tr>
<th className="sticky left-0 z-10 bg-gray-50 text-left font-medium px-3 py-2 min-w-[12rem]">Name</th>
{columns.map(c => (
<th key={c.id} className={`font-medium px-2 py-2 text-center whitespace-nowrap ${c.cancelled ? "text-gray-400 line-through" : ""}`}>
<div>{c.label}</div>
<div className="text-[10px] font-normal text-gray-400">{multi ? colDate.format(new Date(c.startsAt)) : ""}</div>
</th>
))}
<th className="font-medium px-2 py-2 text-center">Total</th>
{certsOn && <th className="font-medium px-3 py-2 text-left">Certificate</th>}
</tr>
</thead>
<tbody className="divide-y">
{rows.map(r => (
<tr key={r.ticketId} className="hover:bg-gray-50/60">
<td className="sticky left-0 z-10 bg-white px-3 py-2">
<div className="font-medium text-gray-900">{r.name || "—"}{r.quantity > 1 && <span className="ml-1 text-xs text-gray-500">×{r.quantity}</span>}</div>
<div className="text-[11px] text-gray-500">{r.variant ? `${r.option} — ${r.variant}` : r.option}{r.bookedBy ? ` · booked by ${r.bookedBy}` : ""}</div>
</td>
{columns.map(c => {
const qty = r.checkIns[c.id] || 0;
const key = `${r.ticketId}:${c.id}`;
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
}
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
return (
<td key={c.id} className="px-2 py-2 text-center">
<button type="button" disabled={disabled} onClick={() => toggle(r, c.id)}
title={canManage ? (qty ? "Present — click to mark absent" : "Absent — click to mark present") : undefined}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border text-xs ${qty ? "bg-green-50 border-green-300 text-green-700" : "bg-white border-gray-200 text-gray-300"} ${disabled ? "cursor-default" : "hover:border-brand-400"} ${pending === key ? "opacity-50" : ""}`}>
{qty ? (r.quantity > 1 ? qty : <Check className="w-4 h-4" />) : "·"}
</button>
</td>
);
})}
<td className="px-2 py-2 text-center">
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${r.completed ? "bg-green-50 text-green-800" : "text-gray-600"}`}>{r.attended}/{r.total}</span>
</td>
{certsOn && (
<td className="px-3 py-2 whitespace-nowrap">
{r.certificate && !r.certificate.revokedAt ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700">{r.certificate.code}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
</div>
) : canManage ? (
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
</button>
) : <span className="text-xs text-gray-400">—</span>}
</td>
)}
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 text-xs text-gray-600">
<tr>
<td className="sticky left-0 bg-gray-50 px-3 py-2 font-medium">Present</td>
{columns.map(c => <td key={c.id} className="px-2 py-2 text-center font-medium">{c.cancelled ? "—" : reg.totals[c.id] || 0}</td>)}
<td />
{certsOn && <td />}
</tr>
</tfoot>
</table>
</div>
)}
{reg && canManage && (
<p className="text-[11px] text-gray-400">Click a box to mark someone present or absent (e.g. they were let in without a scan). Changes are recorded in the audit log.</p>
)}
</div>
)}
</div>
);
}
export default function AttendancePage() {
return (
<Suspense fallback={null}>
<AttendancePageInner />
</Suspense>
);
}
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, API_BASE } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -13,6 +13,12 @@ import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import {
SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate,
type KeyedSession, type AttendanceDraft,
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +95,7 @@ type OptionDraft = {
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
sessionMode?: "all" | "pick"; // "pick sessions" events only
};
type SectionDraft = {
@@ -203,7 +210,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
// ─── Items & Pricing substep editors ────────────────────────────────────────
/** Substep 0 — bare option rows */
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
function OptionsEditor({ options, onChange, required, pickMode }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean; pickMode?: boolean }) {
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
const add = () => {
@@ -239,6 +246,15 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
{pickMode && (
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Pick sessions: bought per session — the price and stock are per session">Sessions</label>
<select className="border rounded px-2 py-1.5 text-sm" value={opt.sessionMode || "all"} onChange={e => upd(i, { sessionMode: e.target.value as "all" | "pick" })}>
<option value="all">All sessions</option>
<option value="pick">Pick sessions (price per session)</option>
</select>
</div>
)}
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -803,8 +819,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
// ─── steps ───────────────────────────────────────────────────────────────────
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback", "Attendance"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
type PricingSubstep = 0 | 1 | 2;
@@ -819,6 +835,8 @@ interface EventDraft {
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
allowTicketTransfer: boolean; collectGender: boolean;
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
scheduleType: "single" | "sessions";
attendanceMode: "whole" | "pick";
}
const blankDraft = (location = ""): EventDraft => ({
@@ -832,8 +850,13 @@ const blankDraft = (location = ""): EventDraft => ({
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
allowTicketTransfer: true, collectGender: false,
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
scheduleType: "single",
attendanceMode: "whole",
});
// "YYYY-MM-DDTHH:MM" (local) → ISO
const isoOf = (local: string) => new Date(local).toISOString();
const blankOptions = (): OptionDraft[] => [
{ name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] }
];
@@ -874,8 +897,37 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
// ── multi-day schedule + attendance settings ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
lateJoinEnabled: !!ev?.lateJoinEnabled,
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
latePricing: ev?.latePricing || "full",
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
checkInWindow: ev?.checkInWindow || "whole_day",
remindersEnabled: !!ev?.sessionReminders,
reminderOffsets: ev?.sessionReminders?.offsets || [180],
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!ev?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
const pickMode = multi && draft.attendanceMode === "pick";
// A multi-session event's dates come from its sessions.
const scheduleBounds = useMemo(() => {
const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt);
if (!active.length) return null;
const end = active.reduce((m, s) => (s.endsAt > m ? s.endsAt : m), active[0].endsAt);
return { start: active[0].startsAt, end };
}, [sessions]);
// Shown after saving when sessions moved or were cancelled, to offer telling registrants.
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions();
@@ -888,6 +940,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
sessionMode: o.sessionMode || "all",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t) => !t.variantId)
@@ -951,8 +1004,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{ type: "text", label: "What could we do better?", isRequired: false, helpText: "" },
] });
}
if (on && !fbSettings.sendAt && draft.endDate) {
const next = new Date(draft.endDate);
const endForFeedback = multi ? scheduleBounds?.end : draft.endDate;
if (on && !fbSettings.sendAt && endForFeedback) {
const next = new Date(endForFeedback);
next.setDate(next.getDate() + 1);
next.setHours(9, 0, 0, 0);
setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) }));
@@ -1036,7 +1090,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update option + variants first to get/confirm variant IDs
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -1068,7 +1122,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
if (created?.id) {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
@@ -1115,13 +1169,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = existingMain.id;
} else {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = created?.id;
}
@@ -1176,16 +1230,36 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (multi) {
const problem = sessionsProblem(sessions);
if (problem) { setError(problem); setStep(0); return; }
if (attendance.lateJoinEnabled && !attendance.lateJoinUntilKey) { setError("Choose until which session late joining is allowed"); setStep(8); return; }
if (attendance.lateJoinEnabled && attendance.latePricing === "fixed" && attendance.lateFixedPrice.trim() === "") { setError("Enter the fixed late price"); setStep(8); return; }
} else if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
}
setSaving(true); setError(null);
try {
const sortedSessions = sortSessions(sessions);
const body: Record<string, unknown> = {
title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
startDate: multi && scheduleBounds ? isoOf(scheduleBounds.start) : new Date(draft.startDate).toISOString(),
endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(),
scheduleType: draft.scheduleType,
...(multi ? {
attendanceMode: draft.attendanceMode,
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled, capacity: pickMode && s.capacity.trim() ? parseInt(s.capacity, 10) : null })),
lateJoinEnabled: attendance.lateJoinEnabled,
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
latePricing: attendance.latePricing,
lateFixedPrice: attendance.latePricing === "fixed" ? attendance.lateFixedPrice : null,
checkInWindow: attendance.checkInWindow,
sessionReminders: attendance.remindersEnabled ? { offsets: attendance.reminderOffsets, channels: attendance.reminderChannels } : null,
} : {}),
certificatesEnabled: attendance.certificatesEnabled,
certificateConfig: attendance.certificate,
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
@@ -1212,13 +1286,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
location: draft.location.trim(),
};
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
const saved = await apiFetch<Event & { sessionChanges?: { moved: number; cancelled: number } | null }>(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id);
const ch = saved?.sessionChanges;
if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled };
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
@@ -1247,7 +1324,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
}
onSuccess(); onClose();
onSuccess();
if (notice) setChangedNotice(notice);
else onClose();
} catch (e) {
setError(errorMessage(e) || "Failed to save event");
} finally {
@@ -1282,7 +1361,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || (multi ? !!sessionsProblem(sessions) : (!draft.startDate || !draft.endDate)) || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
// Not applicable to contact-only events, which have no ticket options at all.
@@ -1339,6 +1418,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div>
)}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{changedNotice && (
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm space-y-2">
<p>
Saved. You {[changedNotice.moved ? `moved ${changedNotice.moved} session${changedNotice.moved === 1 ? "" : "s"}` : "", changedNotice.cancelled ? `cancelled ${changedNotice.cancelled}` : ""].filter(Boolean).join(" and ")}.
Tickets and calendar files show the new schedule, but people who registered aren&apos;t told automatically.
</p>
<div className="flex gap-2">
<a href={`/dashboard/supervisor/email-attendees?eventId=${changedNotice.eventId}`} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700">Message attendees</a>
<button type="button" onClick={onClose} className="px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50">Done</button>
</div>
</div>
)}
{/* ── Step 0: Basic Details ── */}
{step === 0 && (
@@ -1351,10 +1442,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<label className="block text-xs text-gray-600 mb-1">Description</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} />
</div>
<div>
<div className="block text-xs text-gray-600 mb-1">When <span className="text-red-500">*</span></div>
<div className="inline-flex rounded-lg border p-0.5 bg-gray-50">
{([["single", "One date"], ["sessions", "Multiple sessions"]] as const).map(([v, label]) => (
<button key={v} type="button" onClick={() => upd({ scheduleType: v })}
className={`px-3 py-1.5 text-xs rounded-md ${draft.scheduleType === v ? "bg-white shadow-sm text-brand-700 font-medium" : "text-gray-600 hover:text-gray-900"}`}>{label}</button>
))}
</div>
{multi && <p className="text-[10px] text-gray-400 mt-1">One registration and one ticket for every session, e.g. every Wednesday for 5 weeks. Check-in is per session.</p>}
</div>
{multi ? (
<>
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} showCapacity={pickMode} />
<div>
<div className="block text-xs text-gray-600 mb-1">How people book</div>
<div className="space-y-1">
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" checked={draft.attendanceMode === "whole"} onChange={() => upd({ attendanceMode: "whole" })} /><span><span className="font-medium">The whole course</span><span className="block text-xs text-gray-500">One ticket for every session.</span></span></label>
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" disabled={draft.requiresParentalConsent} checked={draft.attendanceMode === "pick"} onChange={() => upd({ attendanceMode: "pick" })} /><span><span className="font-medium">Pick sessions</span><span className="block text-xs text-gray-500">People choose which sessions to come to (e.g. day passes or a drop-in class). Set which tickets are per session in Items &amp; Pricing, and an optional capacity per session above.{draft.requiresParentalConsent ? " Not available on youth events." : ""}</span></span></label>
</div>
</div>
</>
) : (
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<div>
@@ -1415,7 +1531,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{step === 1 && pricingSubstep === 0 && (
<div>
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} pickMode={pickMode} />
</div>
)}
{step === 1 && pricingSubstep === 1 && (
@@ -1431,6 +1547,22 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div>
)}
{/* ── Step 8: Attendance (late joining, check-in, reminders, certificates) ── */}
{step === 8 && (
<AttendanceSettings
value={attendance}
onChange={updAttendance}
multi={multi}
sessions={sessions}
mainPrice={parseFloat(options.find(o => o.isMainTicket)?.price || draft.price || "0") || 0}
uploadButton={(onUploaded) => <UploadImageButton onUploaded={onUploaded} label="Upload" />}
onPreview={async () => {
if (!token) return;
openBlob(await fetchApiFile(API_BASE, token, "/api/attendance/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
}}
/>
)}
{/* ── Step 2: Sections ── */}
{step === 2 && (
<div>
@@ -1714,7 +1846,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
@@ -12,6 +12,8 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -268,29 +270,45 @@ export default function ManualRegistrationPage() {
}
}, [selectedEventId, events]);
// The events list doesn't carry bundle deals; the single-event response does.
// The events list doesn't carry bundle deals or places per session; the single-event
// response does.
const [fullEvent, setFullEvent] = useState<Event | null>(null);
useEffect(() => {
if (!selectedEventId) { setBundles([]); return; }
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setBundles([]); });
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
return () => { cancelled = true; };
}, [selectedEventId, token]);
// "Pick sessions" options: the sessions chosen per quantity key.
const [picks, setPicks] = useState<Record<string, string[]>>({});
useEffect(() => { setPicks({}); }, [selectedEventId]);
const pickOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !fullEvent || !(quantities[key] > 0)) return null;
return <SessionPicker event={fullEvent} option={o} staff quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
};
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
}, 0);
}, [options, quantities]);
}, [options, quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
}), [quantities]);
return { eventOptionId, quantity, variantId: variantId || null, ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
@@ -304,9 +322,11 @@ export default function ManualRegistrationPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
if (noSessions) { setError(`Choose the sessions for "${options.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`); return; }
if (selectedIsYouth) {
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
if (missing) { setError(missing); return; }
@@ -564,7 +584,8 @@ export default function ManualRegistrationPage() {
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
<div key={v.id} className="border-b last:border-b-0">
<div className="flex items-center justify-between px-3 py-2">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
@@ -582,6 +603,8 @@ export default function ManualRegistrationPage() {
/>
</div>
</div>
{picker(key, opt.id) && <div className="px-3 pb-2">{picker(key, opt.id)}</div>}
</div>
);
})}
</div>
@@ -605,6 +628,7 @@ export default function ManualRegistrationPage() {
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
{picker(opt.id, opt.id)}
</div>
);
})}
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
+14 -5
View File
@@ -16,6 +16,8 @@ import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { BundleSavings } from "@/components/shared/BundleDeals";
import { MyCertificates } from "@/components/events/MyCertificates";
import { isPickOption } from "@/lib/sessions";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters
@@ -483,8 +485,13 @@ export default function UserDashboardPage() {
return !isEventOver(activeReg.event);
}, [activeReg]);
// Picked-session lines can't be edited here (book more by registering), but still count.
const pickedLinesTotal = useMemo(() => (activeReg?.registrationOptions || [])
.filter(ro => ro.sessionId)
.reduce((s, ro) => s + (ro.lineTotal ?? (ro.quantity || 0) * (ro.priceSnapshot || 0)), 0), [activeReg]);
const editTotal = useMemo(() => {
return editEventOptions.reduce((sum, eo) => {
return pickedLinesTotal + editEventOptions.reduce((sum, eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum, v) => {
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
@@ -493,7 +500,7 @@ export default function UserDashboardPage() {
}
return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null);
}, 0);
}, [editEventOptions, editQuantities]);
}, [editEventOptions, editQuantities, pickedLinesTotal]);
const beginEdit = async () => {
if (!activeReg || !token) return;
@@ -502,13 +509,13 @@ export default function UserDashboardPage() {
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = eventData?.eventOptions || [];
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
for (const ro of (activeReg.registrationOptions || [])) {
for (const ro of (activeReg.registrationOptions || []).filter(r => !r.sessionId)) {
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0);
}
@@ -518,7 +525,7 @@ export default function UserDashboardPage() {
const minQtyMap: Record<string, number> = {};
for (const t of tickets) {
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
if (!ro) continue;
if (!ro || ro.sessionId) continue;
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
}
@@ -774,6 +781,8 @@ export default function UserDashboardPage() {
</div>
)}
<MyCertificates token={token} />
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
@@ -6,6 +6,12 @@ import { API_BASE } from "@/lib/api";
type Props = { event: { id: string; title: string; description?: string | null } };
// The event's own address tagged with where the link was handed out, so visits from it
// show under that source in the funnel report (the page's canonical drops the tag for SEO).
function taggedEventUrl(source: string): string {
return `${window.location.origin}${window.location.pathname}?utm_source=${source}`;
}
export default function ClientActions({ event }: Props) {
return (
<div className="flex gap-2">
@@ -15,7 +21,7 @@ export default function ClientActions({ event }: Props) {
await navigator.share({
title: event.title,
text: event.description ?? undefined,
url: window.location.href,
url: taggedEventUrl("share"),
});
} catch (err) {
console.error("Error sharing:", err);
@@ -29,7 +35,7 @@ export default function ClientActions({ event }: Props) {
<button
onClick={async () => {
try {
const url = window.location.href;
const url = taggedEventUrl("qr");
const qrDataUrl = await QRCode.toDataURL(url);
const link = document.createElement("a");
link.href = qrDataUrl;
+47 -10
View File
@@ -21,6 +21,7 @@ type EventOption = {
stockLimit?: number;
availableCount?: number;
isMainTicket?: boolean;
sessionMode?: "all" | "pick";
earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[];
variants?: OptionVariant[];
};
@@ -47,13 +48,23 @@ type Event = {
waitlistEnabled?: boolean;
allowSponsorships?: boolean;
bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { SessionSchedule } from "@/components/events/SessionSchedule";
import { appUrl, appName } from "@/lib/siteConfig";
import { getServerSettings } from "@/lib/serverSettings";
import { eventJsonLd, jsonLdString } from "@/lib/eventSchema";
// Tiered low-stock threshold: larger events use a smaller percentage.
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
@@ -79,11 +90,10 @@ function RegisterCta({ event }: { event: Event }) {
);
}
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
// Multi-day events close when the first session starts (or later with late joining).
const closed = now >= registrationClosesAt(event);
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
@@ -147,10 +157,14 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return {
title: event.title,
description,
// One address per event for search engines, however the page was reached
// (?utm_source=…, ?src=…, an alias link).
alternates: { canonical: `/events/${event.id}` },
openGraph: {
title: event.title,
description,
type: "website",
url: `/events/${event.id}`,
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
@@ -167,8 +181,18 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
const event = await loadEvent(id);
if (!event) notFound();
const settings = await getServerSettings();
const siteUrl = appUrl.replace(/\/$/, "");
const jsonLd = eventJsonLd(event, {
pageUrl: `${siteUrl}/events/${event.id}`,
imageUrl: event.picture ? resolveToApiOrigin(event.picture) : null,
organizerName: settings.org_name || appName,
siteUrl,
});
return (
<div className="min-h-screen flex flex-col">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(jsonLd) }} />
<Navbar />
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="view" />
@@ -186,7 +210,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
<Calendar className="w-4 h-4 shrink-0" />
{formatDateTimeRange(event.startDate, event.endDate)}
{scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate)}
</p>
<div className="mt-3">
<ClientActions event={event} />
@@ -195,6 +219,10 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.scheduleType === "sessions" && (event.sessions || []).length > 0 && (
<SessionSchedule sessions={event.sessions || []} defaultLocation={event.location} />
)}
{event.location && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
@@ -264,16 +292,25 @@ function TicketList({ event }: { event: Event }) {
</div>
);
}
// Late joiners on a multi-day event pay a lower main-ticket price.
const late = event.lateJoin?.active ? event.lateJoin : null;
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket && opt.sessionMode !== "pick" ? latePrice(price, late) : price);
const perSession = (opt: EventOption) => (isPickOption(event, opt) ? " per session" : "");
return (
<div className="space-y-2">
<BundleOffers bundles={event.bundles} />
{late && (
<p className="text-xs text-brand-800 bg-brand-50 border border-brand-200 rounded-lg px-3 py-2">
Joining late? {late.sessionsLeft} of {late.totalSessions} sessions are still to come, so the ticket price below is {late.mode === "prorata" ? "for the sessions left" : "the late-joiner price"}.
</p>
)}
{options.map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
if (hasVariants) {
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
const prices = opt.variants!.map((v) => forLate(opt, v.price !== null && v.price !== undefined ? v.price : opt.price));
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
@@ -282,14 +319,14 @@ function TicketList({ event }: { event: Event }) {
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span className="font-medium text-sm">{opt.name}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{priceLabel}</span>
<span className="text-sm text-gray-600">{priceLabel}{perSession(opt)}</span>
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
<ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const vPrice = forLate(opt, v.price !== null && v.price !== undefined ? v.price : opt.price);
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
return (
@@ -313,7 +350,7 @@ function TicketList({ event }: { event: Event }) {
const activeTiers = (opt.earlyBirdTiers || [])
.filter((t) => now < new Date(t.deadline))
.sort((a, b) => a.price - b.price);
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
const displayPrice = forLate(opt, activeTiers.length > 0 ? activeTiers[0].price : opt.price);
return (
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
@@ -326,7 +363,7 @@ function TicketList({ event }: { event: Event }) {
<span className="text-xs text-brand-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}${perSession(opt)}`}</span>
)}
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
+4 -1
View File
@@ -10,6 +10,7 @@ export const revalidate = 60;
export const metadata: Metadata = {
title: "All Events",
description: "Browse and register for upcoming events.",
alternates: { canonical: "/events" },
};
type Event = {
@@ -19,6 +20,8 @@ type Event = {
startDate: string;
endDate: string;
registrationDeadline?: string | null;
// Multi-day events: listed by their next session, not their first.
nextSession?: { startsAt: string } | null;
goLiveAt?: string;
price: number;
picture?: string;
@@ -34,7 +37,7 @@ export default async function EventsPage() {
const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const sorted = [...upcoming].sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
+4 -23
View File
@@ -2,40 +2,19 @@ import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { resolveToApiOrigin } from "@/lib/api";
import { getServerSettings } from "@/lib/serverSettings";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
/**
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
* below. Both call this with the identical URL + options so Next's per-request
* fetch memoization dedupes them into a single network call. Degrades to `{}`
* (static defaults everywhere downstream) if the backend is unreachable at
* request time — intentional, not a bug: branding just isn't critical enough
* to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
@@ -63,6 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
description,
...(logoUrl ? { images: [logoUrl] } : {}),
},
// Per-deployment Search Console ownership check (Settings → Organisation).
...(settings.google_site_verification ? { verification: { google: settings.google_site_verification } } : {}),
};
}
+8 -1
View File
@@ -5,8 +5,13 @@ import { Footer } from "@/components/layout/Footer";
import { JoinUsButton } from "@/components/home/JoinUsButton";
import { appName } from "@/lib/siteConfig";
import { API_BASE } from "@/lib/api";
import type { Metadata } from "next";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
export const metadata: Metadata = {
alternates: { canonical: "/" },
};
type Event = {
id: string;
title: string;
@@ -14,6 +19,8 @@ type Event = {
startDate: string;
endDate: string;
registrationDeadline?: string | null;
// Multi-day events: listed by their next session, not their first.
nextSession?: { startsAt: string } | null;
goLiveAt?: string;
price: number;
picture?: string;
@@ -50,7 +57,7 @@ export default async function HomePage() {
const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const sorted = [...upcoming].sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
@@ -5,7 +5,9 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { analyticsSessionId } from "@/lib/analytics";
@@ -32,6 +34,8 @@ type EventOption = {
isMainTicket?: boolean;
earlyBirdTiers?: EarlyBirdTier[];
variants?: OptionVariant[];
sessionMode?: "all" | "pick";
sessionAvailability?: Record<string, number>;
};
export type RegisterEvent = {
@@ -52,8 +56,30 @@ export type RegisterEvent = {
collectGender?: boolean;
waitlistEnabled?: boolean;
bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
registrationClosesAt?: string;
wholeCourseClosesAt?: string | null;
lateJoin?: LateJoinInfo | null;
};
// Late joiners on a multi-day event: show main tickets at the late price (the server
// charges the same — see backend utils/sessions.js).
function withLatePrices(event: RegisterEvent): RegisterEvent {
const late = event.lateJoin?.active ? event.lateJoin : null;
if (!late) return event;
return {
...event,
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket || o.sessionMode === "pick" ? o : {
...o,
price: latePrice(o.price, late),
earlyBirdTiers: (o.earlyBirdTiers || []).map(t => ({ ...t, price: latePrice(t.price, late) })),
variants: (o.variants || []).map(v => (v.price === null || v.price === undefined ? v : { ...v, price: latePrice(v.price, late) })),
})),
};
}
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
@@ -135,18 +161,27 @@ function QtyControl({
);
}
// A "pick sessions" option: the sessions chosen per quantity key, and the event's schedule.
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void };
function OptionCard({
opt,
quantities,
onQtyChange,
waitlist,
pick,
}: {
opt: EventOption;
quantities: QtyMap;
onQtyChange: (key: string, val: number) => void;
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
pick?: PickProps | null;
}) {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const perSession = pick ? " per session" : "";
const picker = (key: string, qty: number, unit: number) => (pick && qty > 0 ? (
<SessionPicker event={pick.event} option={opt} selected={pick.picks[key] || []} onChange={(ids) => pick.onPick(key, ids)} quantity={qty} unitPrice={unit} />
) : null);
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
if (hasVariants) {
@@ -156,7 +191,7 @@ function OptionCard({
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
const minPrice = Math.min(...variantPrices);
const maxPrice = Math.max(...variantPrices);
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
const priceLabel = (minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`) + perSession;
return (
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
@@ -193,10 +228,11 @@ function OptionCard({
: undefined;
return (
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
<div key={variant.id} className="px-4 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm">{variant.name}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}{perSession}</span>
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
{waitlist && variantSoldOut && !isSoldOut && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
@@ -210,6 +246,8 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, unitPrice)}
</div>
);
})}
</div>
@@ -227,7 +265,8 @@ function OptionCard({
: undefined;
return (
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
<div className="border rounded-lg p-3 bg-white shadow-sm">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 mr-3">
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
@@ -239,7 +278,7 @@ function OptionCard({
</span>
</>
) : (
<span>R{price.toFixed(2)}</span>
<span>R{price.toFixed(2)}{perSession}</span>
)}
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
{waitlist && isSoldOut && (
@@ -254,6 +293,8 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, price)}
</div>
);
}
@@ -350,10 +391,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
.catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid."));
}, []);
const event = useMemo<RegisterEvent>(() => {
if (!offer) return baseEvent;
const priced = withLatePrices(baseEvent);
if (!offer) return priced;
return {
...baseEvent,
eventOptions: (baseEvent.eventOptions || []).map(o => {
...priced,
eventOptions: (priced.eventOptions || []).map(o => {
if (o.id !== offer.eventOptionId) return o;
return {
...o,
@@ -400,28 +442,36 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const now = new Date();
const end = event.endDate ? new Date(event.endDate) : null;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
if ((deadline && now >= deadline) || (end && now >= end)) {
if ((deadline && now >= deadline) || (end && now >= end) || now >= registrationClosesAt(event)) {
window.location.replace('/events');
}
} catch {}
}, [event.endDate, event.registrationDeadline]);
}, [event]);
const handleQtyChange = (key: string, val: number) => {
setQuantities((q) => ({ ...q, [key]: val }));
};
// "Pick sessions" options: the sessions chosen for each quantity key. Whole-course
// tickets on such an event stop selling when the course starts.
const [picks, setPicks] = useState<Record<string, string[]>>({});
const wholeCourseClosed = event.attendanceMode === "pick" && !!event.wholeCourseClosesAt && Date.now() >= new Date(event.wholeCourseClosesAt).getTime();
const pickFor = (opt: EventOption) => isPickOption(event, opt);
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
const total = useMemo(() => {
return (event.eventOptions || []).reduce((sum, opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
if (hasVariants) {
return sum + opt.variants!.reduce((vsum, v) => {
const qty = quantities[`${opt.id}:${v.id}`] || 0;
return vsum + qty * effectiveUnitForVariant(opt, v);
const key = `${opt.id}:${v.id}`;
const qty = quantities[key] || 0;
return vsum + qty * sessionsFor(key, opt) * effectiveUnitForVariant(opt, v);
}, 0);
}
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
return sum + (quantities[opt.id] || 0) * sessionsFor(opt.id, opt) * effectiveUnitForOption(opt);
}, 0);
}, [event, quantities]);
}, [event, quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
const isGuestEligible = !!(event.requiresAuth === false);
const isLoggedIn = !!user || !!token;
@@ -432,7 +482,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
const basket = useMemo(() => buildItems(), [quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
// a code, so its total wins.
@@ -450,15 +500,13 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
: 0;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
const items: { eventOptionId: string; quantity: number; variantId?: string; sessionIds?: string[] }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
if (key.includes(":")) {
const [eventOptionId, variantId] = key.split(":");
items.push({ eventOptionId, quantity: qty, variantId });
} else {
items.push({ eventOptionId: key, quantity: qty });
}
const [eventOptionId, variantId] = key.includes(":") ? key.split(":") : [key, undefined];
const opt = (event.eventOptions || []).find(o => o.id === eventOptionId);
const sessionIds = opt && pickFor(opt) ? picks[key] || [] : undefined;
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
});
return items;
}
@@ -469,6 +517,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
setError("Select at least one ticket.");
return;
}
const noSessions = items.find(i => i.sessionIds && i.sessionIds.length === 0);
if (noSessions) {
setError(`Choose the sessions for "${(event.eventOptions || []).find(o => o.id === noSessions.eventOptionId)?.name || "your ticket"}".`);
return;
}
if (isYouthEvent) {
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
if (missing) { setError(missing); return; }
@@ -638,14 +691,18 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
{wholeCourseClosed && (
<p className="mb-3 text-sm text-gray-600 bg-gray-50 border rounded-lg px-3 py-2">The course has started, so whole-course tickets are no longer on sale — choose the sessions you&apos;d like to come to.</p>
)}
<div className="space-y-3">
{(event.eventOptions || []).map((opt) => (
{(event.eventOptions || []).filter(opt => !(wholeCourseClosed && !pickFor(opt))).map((opt) => (
<OptionCard
key={opt.id}
opt={opt}
quantities={quantities}
onQtyChange={handleQtyChange}
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
pick={pickFor(opt) ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })) } : null}
/>
))}
</div>
@@ -672,6 +729,12 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
</div>
)}
{event.lateJoin?.active && (
<div className="mt-4 p-3 rounded-lg bg-brand-50 border border-brand-200 text-sm text-brand-900">
<strong>Joining late:</strong> {event.lateJoin.sessionsLeft} of {event.lateJoin.totalSessions} sessions are still to come, so main tickets are at the {event.lateJoin.mode === "prorata" ? "price for the sessions left" : "late-joiner price"}.
</div>
)}
{(() => {
// Collect all upcoming early-bird tiers across all options
const now = new Date();
+41 -17
View File
@@ -8,7 +8,9 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { PublicBundle, Registration } from "@/types";
import type { Event, PublicBundle, Registration } from "@/types";
import { isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -209,25 +211,41 @@ export default function SelfServicePage() {
[events, selectedEventId]
);
// The events list doesn't carry bundle deals or places per session; the single-event
// response does.
const [kioskFull, setKioskFull] = useState<Event | null>(null);
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
// "Pick sessions" options: the sessions chosen per quantity key.
const [picks, setPicks] = useState<Record<string, string[]>>({});
useEffect(() => { setPicks({}); }, [selectedEventId]);
const pickOpt = (optionId: string) => {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && isPickOption(kioskFull, o) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !kioskFull || !(quantities[key] > 0)) return null;
return <SessionPicker event={kioskFull} option={o} quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
};
const total = useMemo(() => {
if (!selectedEvent) return 0;
return (selectedEvent.eventOptions || []).reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveUnit(o);
return sum + (quantities[o.id] || 0) * times(o.id) * effectiveUnit(o);
}, 0);
}, [selectedEvent, quantities]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); return; }
let cancelled = false;
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setKioskBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
}, [selectedEvent, quantities, picks, kioskFull]); // eslint-disable-line react-hooks/exhaustive-deps
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
@@ -438,7 +456,7 @@ export default function SelfServicePage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}),
user,
// If the visitor declined to create an account, don't push an unsolicited
@@ -973,7 +991,8 @@ export default function SelfServicePage() {
const baseVariantPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-3">
<div key={v.id}>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-gray-800">{v.name}</p>
<p className="text-sm text-gray-500">
@@ -999,6 +1018,8 @@ export default function SelfServicePage() {
</button>
</div>
</div>
{picker(key, opt.id) && <div className="px-4 pb-3">{picker(key, opt.id)}</div>}
</div>
);
})}
</div>
@@ -1007,7 +1028,8 @@ export default function SelfServicePage() {
}
const unit = effectiveUnit(opt);
return (
<div key={opt.id} className="flex items-center justify-between border border-gray-200 rounded-xl px-4 py-3.5">
<div key={opt.id} className="border border-gray-200 rounded-xl px-4 py-3.5">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{opt.name}</p>
<p className="text-sm text-gray-500">{fmtCurrency(unit)}{unit !== opt.price && opt.price > 0 ? ` (was ${fmtCurrency(opt.price)})` : ""}</p>
@@ -1030,6 +1052,8 @@ export default function SelfServicePage() {
</button>
</div>
</div>
{picker(opt.id, opt.id)}
</div>
);
})}
</div>
+143
View File
@@ -0,0 +1,143 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Timer, Play, RefreshCw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
type JobsResponse = { mode: "queue" | "timers" | "off"; timezone: string; jobs: JobInfo[] };
const STATE: Record<string, string> = {
completed: "bg-green-50 text-green-800",
failed: "bg-red-50 text-red-700",
active: "bg-blue-50 text-blue-700",
created: "bg-gray-100 text-gray-600",
retry: "bg-amber-50 text-amber-800",
cancelled: "bg-gray-100 text-gray-500",
};
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
function describeCron(cron: string): string {
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
if (m) return `Every ${m[1]} minutes`;
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
if (d) return `Daily at ${d[2].padStart(2, "0")}:${d[1].padStart(2, "0")}`;
return cron;
}
function outputText(run: JobRun): string {
if (run.output === null || run.output === undefined) return "";
if (typeof run.output === "object" && run.output && "message" in run.output && run.state === "failed") return String((run.output as { message: unknown }).message);
try { return JSON.stringify(run.output); } catch { return ""; }
}
// Settings → Background jobs: what runs when, the last runs of each job, and "Run now".
export function JobsTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [data, setData] = useState<JobsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
} finally {
setLoading(false);
}
}, [token, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const runNow = async (job: JobInfo) => {
if (!token) return;
setRunning(job.name);
try {
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
setTimeout(load, r.queued ? 4000 : 0);
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Couldn't run the job" });
} finally {
setRunning(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><Timer className="w-5 h-5 text-brand-600" /> Background jobs</h2>
<p className="text-sm text-gray-500 mt-0.5">
Tasks the server runs on a schedule ({data?.timezone || "local"} time). Each runs once per schedule, even with more than one server process, and failed runs are retried.
</p>
</div>
<button type="button" onClick={load} disabled={loading} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50 shrink-0">
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> Refresh
</button>
</div>
{message && <div className={`p-3 border rounded text-sm ${message.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
{data?.mode === "timers" && (
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">The job queue couldn&apos;t start, so jobs are running on simple timers in this server process (no retries, history only since the last restart). Check the server log for &quot;[jobs]&quot;.</div>
)}
{data?.mode === "off" && (
<div className="p-3 border rounded bg-gray-50 text-gray-700 text-sm">All background jobs are switched off (JOBS_ENABLED=false).</div>
)}
<ul className="divide-y border rounded-lg">
{(data?.jobs || []).map(job => {
const last = job.recent[0];
return (
<li key={job.name} className="p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 mr-auto">
<div className="font-medium text-sm text-gray-900">{job.label}</div>
<div className="text-xs text-gray-500">
{job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`}
</div>
</div>
{last && (
<button type="button" onClick={() => setOpen(open === job.name ? null : job.name)} className={`text-xs px-2 py-0.5 rounded ${STATE[last.state] || "bg-gray-100 text-gray-600"}`} title="Show recent runs">
{last.state} · {when(last.completedOn || last.startedOn || last.createdOn || null)}
</button>
)}
<button type="button" disabled={running !== null || data?.mode === "off"} onClick={() => runNow(job)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
<Play className="w-3.5 h-3.5" /> {running === job.name ? "Starting…" : "Run now"}
</button>
</div>
{open === job.name && job.recent.length > 0 && (
<table className="mt-2 w-full text-xs">
<thead className="text-gray-500"><tr><th className="text-left font-medium py-1">Started</th><th className="text-left font-medium">Finished</th><th className="text-left font-medium">State</th><th className="text-left font-medium">Result</th></tr></thead>
<tbody>
{job.recent.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-1 pr-2 whitespace-nowrap">{when(r.startedOn)}</td>
<td className="pr-2 whitespace-nowrap">{when(r.completedOn)}</td>
<td className="pr-2"><span className={`px-1.5 py-0.5 rounded ${STATE[r.state] || ""}`}>{r.state}</span></td>
<td className="font-mono text-[11px] text-gray-600 break-all">{outputText(r)}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
})}
{!loading && data && data.jobs.length === 0 && <li className="p-3 text-sm text-gray-500">No jobs.</li>}
</ul>
<p className="text-xs text-gray-400">Change a schedule with the env var JOB_SCHEDULE_&lt;NAME&gt; (cron, e.g. JOB_SCHEDULE_WAITLIST=&quot;*/5 * * * *&quot;), or switch one off with its own flag.</p>
</div>
);
}
+31 -7
View File
@@ -19,16 +19,44 @@ import { ContactButton } from "@/components/events/ContactButton";
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
scheduleType?: "single" | "sessions";
sessions?: EventSession[];
registrationClosesAt?: string;
};
import { formatDateTimeRange } from "@/lib/date";
import { registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import type { EventSession } from "@/types";
export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
const dateRange = scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate);
const now = new Date();
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
// Multi-day events close when the first session starts (or later with late joining).
const closesAt = registrationClosesAt(event);
const registrationOpen = event.requiresRegistration !== false
&& !(goLive && now < goLive)
&& now < closesAt;
const showEarlyBird = !!event.earlyBirdEndsAt && registrationOpen && !event.isSoldOut;
return (
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
<div className="relative border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
{showEarlyBird && (
<div className="pointer-events-none absolute top-0 right-0 z-10 h-32 w-32 overflow-hidden" aria-hidden="true">
<div className="absolute top-[26px] -right-[38px] w-[150px] rotate-45 bg-amber-400 py-1 text-center text-xs font-bold uppercase tracking-wide text-amber-950 shadow-md">
Early bird
</div>
</div>
)}
<a href={`/events/${event.id}`} className="block">
{showEarlyBird && (
<span className="sr-only">
Early bird pricing until {new Date(event.earlyBirdEndsAt!).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}.
</span>
)}
{event.picture ? (
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
) : (
@@ -69,12 +97,8 @@ export const EventCard = ({ event }: { event: Event }) => {
/>
);
}
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
const closed = now >= closesAt;
if (notYetOpen) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration not yet open">
@@ -0,0 +1,59 @@
"use client";
import React, { useEffect, useState } from "react";
import { Award, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { fetchApiFile, saveBlob } from "@/lib/export";
import { formatDate } from "@/lib/date";
import { errorMessage } from "@/lib/utils";
import type { MyCertificate } from "@/types";
// Completion certificates on My Events. Hidden until the person has one.
export function MyCertificates({ token }: { token: string | null }) {
const [certs, setCerts] = useState<MyCertificate[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<MyCertificate[]>("/api/attendance/certificates/mine", { authToken: token })
.then(list => setCerts(Array.isArray(list) ? list : []))
.catch(() => setCerts([]));
}, [token]);
if (certs.length === 0) return null;
const download = async (c: MyCertificate) => {
if (!token) return;
setError(null);
try {
saveBlob(await fetchApiFile(API_BASE, token, `/api/attendance/certificates/${c.id}/pdf`), `Certificate - ${c.event.title.replace(/[^a-z0-9 -]/gi, "")}.pdf`);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
};
return (
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Award className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold text-gray-900">My Certificates</h2>
</div>
{error && <div className="mb-3 p-2 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<ul className="divide-y">
{certs.map(c => (
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
<div className="min-w-0">
<div className="font-medium text-gray-900">{c.event.title}</div>
<div className="text-xs text-gray-500">
{c.name} · {c.totalSessions > 1 ? `${c.sessionsAttended} of ${c.totalSessions} sessions · ` : ""}issued {formatDate(new Date(c.issuedAt))}
</div>
</div>
<button type="button" onClick={() => download(c)} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100">
<Download className="w-4 h-4" /> Download
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,430 @@
"use client";
import React, { useMemo, useState } from "react";
import { CalendarDays, Plus, RotateCcw, Trash2, Ban, FileText, Repeat } from "lucide-react";
import type { CertificateConfig, CertificateTemplate } from "@/types";
import {
generateSessions, formatSessionWhen, CERTIFICATE_TEMPLATES, REMINDER_OPTIONS, DEFAULT_CERTIFICATE_BODY,
type SessionDraft, type SessionPattern,
} from "@/lib/sessions";
// ─── Sessions (Basic Details step) ────────────────────────────────────────────
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const newKey = () => Math.random().toString(36).slice(2, 10);
export type KeyedSession = SessionDraft & { key: string };
export function keyed(s: SessionDraft): KeyedSession {
return { ...s, key: s.id || newKey() };
}
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Sorts by start; rows still missing a date go last. */
export function sortSessions(list: KeyedSession[]): KeyedSession[] {
return [...list].sort((a, b) => (a.startsAt || "9999").localeCompare(b.startsAt || "9999"));
}
/** Problems that block saving, or null. */
export function sessionsProblem(list: KeyedSession[]): string | null {
if (!list.some(s => !s.cancelled)) return "Add at least one session";
for (const [i, s] of sortSessions(list).entries()) {
if (!s.startsAt || !s.endsAt) return `Session ${i + 1} needs a start and end`;
if (new Date(s.endsAt) <= new Date(s.startsAt)) return `Session ${i + 1} must end after it starts`;
}
return null;
}
function PatternBuilder({ onGenerate }: { onGenerate: (rows: SessionDraft[], mode: "replace" | "add") => void }) {
const [p, setP] = useState<SessionPattern>(() => ({
frequency: "weekly", interval: 1, weekdays: [new Date().getDay()], startDate: todayISO(),
startTime: "18:30", endTime: "20:30", endsBy: "count", count: 5, until: "",
}));
const set = (patch: Partial<SessionPattern>) => setP(x => ({ ...x, ...patch }));
const preview = useMemo(() => generateSessions(p), [p]);
const toggleDay = (d: number) => set({ weekdays: p.weekdays.includes(d) ? p.weekdays.filter(x => x !== d) : [...p.weekdays, d] });
return (
<div className="border rounded-lg p-3 bg-brand-50/40 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-gray-800"><Repeat className="w-4 h-4 text-brand-600" /> Repeat pattern</div>
<div className="grid sm:grid-cols-3 gap-2">
<label className="text-xs text-gray-600">Repeats
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.frequency} onChange={e => set({ frequency: e.target.value as SessionPattern["frequency"] })}>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</label>
<label className="text-xs text-gray-600">Every
<div className="mt-1 flex items-center gap-1.5">
<input type="number" min={1} max={12} className="w-16 border rounded px-2 py-1.5 text-sm" value={p.interval} onChange={e => set({ interval: Math.max(1, parseInt(e.target.value) || 1) })} />
<span className="text-sm text-gray-600">{p.frequency === "weekly" ? (p.interval === 1 ? "week" : "weeks") : (p.interval === 1 ? "day" : "days")}</span>
</div>
</label>
<label className="text-xs text-gray-600">First date
<input type="date" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.startDate} onChange={e => set({ startDate: e.target.value })} />
</label>
</div>
{p.frequency === "weekly" && (
<div>
<div className="text-xs text-gray-600 mb-1">On</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((d, i) => (
<button key={d} type="button" onClick={() => toggleDay(i)}
className={`px-2.5 py-1 text-xs rounded-full border ${p.weekdays.includes(i) ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"}`}>{d}</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<label className="text-xs text-gray-600">From
<input type="time" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.startTime} onChange={e => set({ startTime: e.target.value })} />
</label>
<label className="text-xs text-gray-600">To
<input type="time" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.endTime} onChange={e => set({ endTime: e.target.value })} />
</label>
<label className="text-xs text-gray-600">Ends
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.endsBy} onChange={e => set({ endsBy: e.target.value as SessionPattern["endsBy"] })}>
<option value="count">After a number of sessions</option>
<option value="until">On a date</option>
</select>
</label>
{p.endsBy === "count" ? (
<label className="text-xs text-gray-600">Sessions
<input type="number" min={1} max={200} className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.count} onChange={e => set({ count: Math.max(1, Math.min(200, parseInt(e.target.value) || 1)) })} />
</label>
) : (
<label className="text-xs text-gray-600">Last date
<input type="date" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.until} onChange={e => set({ until: e.target.value })} />
</label>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-gray-500">
{preview.length === 0 ? "Pick the dates and times to see the sessions." : `${preview.length} session${preview.length === 1 ? "" : "s"}: ${formatSessionWhen({ startsAt: preview[0].startsAt, endsAt: preview[0].endsAt })}${preview.length > 1 ? ` … ${formatSessionWhen({ startsAt: preview[preview.length - 1].startsAt, endsAt: preview[preview.length - 1].endsAt })}` : ""}`}
</p>
<div className="flex gap-2">
<button type="button" disabled={!preview.length} onClick={() => onGenerate(preview, "add")} className="px-3 py-1.5 text-xs rounded border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-50">Add to list</button>
<button type="button" disabled={!preview.length} onClick={() => onGenerate(preview, "replace")} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Create sessions</button>
</div>
</div>
</div>
);
}
export function SessionsEditor({ sessions, onChange, defaultLocation, showCapacity }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string; showCapacity?: boolean }) {
const [showPattern, setShowPattern] = useState(sessions.length === 0);
const sorted = sortSessions(sessions);
const activeCount = sessions.filter(s => !s.cancelled).length;
const update = (key: string, patch: Partial<KeyedSession>) => onChange(sessions.map(s => (s.key === key ? { ...s, ...patch } : s)));
const remove = (key: string) => onChange(sessions.filter(s => s.key !== key));
const generate = (rows: SessionDraft[], mode: "replace" | "add") => {
const fresh = rows.map(keyed);
// Saved sessions with check-ins can't be removed, so "replace" keeps them.
const kept = mode === "replace" ? sessions.filter(s => s.hasCheckIns) : sessions;
onChange(sortSessions([...kept, ...fresh]));
setShowPattern(false);
};
const addOne = () => {
const last = sorted[sorted.length - 1];
const base = last?.startsAt ? new Date(last.startsAt) : new Date();
const dur = last?.startsAt && last?.endsAt ? new Date(last.endsAt).getTime() - new Date(last.startsAt).getTime() : 2 * 60 * 60 * 1000;
const start = new Date(base.getTime() + (last ? 7 * 24 * 60 * 60 * 1000 : 0));
const p = (n: number) => String(n).padStart(2, "0");
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false, capacity: last?.capacity || "" })]);
};
let position = 0;
return (
<div className="space-y-3">
{showPattern
? <PatternBuilder onGenerate={generate} />
: (
<button type="button" onClick={() => setShowPattern(true)} className="inline-flex items-center gap-1.5 text-xs text-brand-700 hover:underline">
<Repeat className="w-3.5 h-3.5" /> Create sessions from a repeat pattern
</button>
)}
{sorted.length > 0 && (
<div className="border rounded-lg divide-y">
<div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 rounded-t-lg">
{activeCount} session{activeCount === 1 ? "" : "s"} — edit any date, add a label, or cancel one (e.g. a public holiday).
</div>
{sorted.map((s) => {
if (!s.cancelled) position += 1;
return (
<div key={s.key} className={`p-3 space-y-2 ${s.cancelled ? "bg-gray-50" : ""}`}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold w-6 shrink-0 ${s.cancelled ? "text-gray-400" : "text-brand-700"}`}>{s.cancelled ? "—" : position}</span>
<input className={`flex-1 min-w-0 border rounded px-2 py-1 text-sm ${s.cancelled ? "line-through text-gray-400" : ""}`} placeholder={s.cancelled ? "Cancelled" : `Session ${position} (optional label)`}
value={s.label} onChange={e => update(s.key, { label: e.target.value })} disabled={s.cancelled} />
{s.cancelled ? (
<button type="button" onClick={() => update(s.key, { cancelled: false })} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50" title="Restore this session">
<RotateCcw className="w-3.5 h-3.5" /> Restore
</button>
) : s.id ? (
<button type="button" onClick={() => update(s.key, { cancelled: true })} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100" title="Cancel this session (kept for history)">
<Ban className="w-3.5 h-3.5" /> Cancel
</button>
) : null}
{!s.hasCheckIns && (
<button type="button" onClick={() => remove(s.key)} className="p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50" title="Remove">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{!s.cancelled && (
<div className={`grid gap-2 pl-8 ${showCapacity ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}>
<label className="text-[11px] text-gray-500">Starts
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.startsAt} onChange={e => {
const v = e.target.value;
// Keep the session's length when the start moves.
const len = s.startsAt && s.endsAt ? new Date(s.endsAt).getTime() - new Date(s.startsAt).getTime() : 0;
const p2 = (n: number) => String(n).padStart(2, "0");
const end = v && len > 0 ? new Date(new Date(v).getTime() + len) : null;
update(s.key, { startsAt: v, ...(end ? { endsAt: `${end.getFullYear()}-${p2(end.getMonth() + 1)}-${p2(end.getDate())}T${p2(end.getHours())}:${p2(end.getMinutes())}` } : {}) });
}} />
</label>
<label className="text-[11px] text-gray-500">Ends
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.endsAt} onChange={e => update(s.key, { endsAt: e.target.value })} />
</label>
<label className="text-[11px] text-gray-500">Location (optional)
<input className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder={defaultLocation || "Event location"} value={s.location} onChange={e => update(s.key, { location: e.target.value })} />
</label>
{showCapacity && (
<label className="text-[11px] text-gray-500" title="Most people at this session (picked places plus whole-course tickets). Blank = no limit.">Capacity
<input type="number" min={1} className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder="No limit" value={s.capacity} onChange={e => update(s.key, { capacity: e.target.value })} />
</label>
)}
</div>
)}
{s.hasCheckIns && <p className="pl-8 text-[10px] text-gray-400">Has check-ins — it can be cancelled but not removed.</p>}
</div>
);
})}
</div>
)}
<button type="button" onClick={addOne} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded border border-gray-200 bg-white hover:bg-gray-50">
<Plus className="w-3.5 h-3.5" /> Add a date
</button>
</div>
);
}
// ─── Attendance step ──────────────────────────────────────────────────────────
export type AttendanceDraft = {
lateJoinEnabled: boolean;
lateJoinUntilKey: string;
latePricing: "full" | "prorata" | "fixed";
lateFixedPrice: string;
checkInWindow: "whole_day" | "session_only";
remindersEnabled: boolean;
reminderOffsets: number[];
reminderChannels: ("email" | "whatsapp")[];
certificatesEnabled: boolean;
certificate: CertificateConfig;
};
export function defaultCertificate(): CertificateConfig {
return { template: "classic", title: "Certificate of Completion", body: DEFAULT_CERTIFICATE_BODY, signatureName: "", signatureRole: "", signatureImage: "", rule: { type: "all", value: null }, sendOnIssue: false };
}
function Toggle({ id, checked, onChange, title, children }: { id: string; checked: boolean; onChange: (v: boolean) => void; title: string; children?: React.ReactNode }) {
return (
<div className="flex items-start gap-2">
<input id={id} type="checkbox" className="mt-0.5" checked={checked} onChange={e => onChange(e.target.checked)} />
<label htmlFor={id} className="text-sm text-gray-800">
<span className="font-medium">{title}</span>
{children && <span className="block text-xs text-gray-500 mt-0.5">{children}</span>}
</label>
</div>
);
}
export function AttendanceSettings({ value, onChange, multi, sessions, mainPrice, onPreview, uploadButton }: {
value: AttendanceDraft;
onChange: (patch: Partial<AttendanceDraft>) => void;
multi: boolean;
sessions: KeyedSession[];
mainPrice: number;
onPreview: () => Promise<void>;
uploadButton: (onUploaded: (url: string) => void) => React.ReactNode;
}) {
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const active = sortSessions(sessions).filter(s => !s.cancelled);
const cert = value.certificate;
const setCert = (patch: Partial<CertificateConfig>) => onChange({ certificate: { ...cert, ...patch } });
const total = active.length;
const examplePrice = mainPrice > 0 ? mainPrice : 300;
const example = total > 1
? (() => {
const left = total - 1;
const v = Math.min(examplePrice, Math.max(10, Math.round((examplePrice * left / total) / 10) * 10));
return `e.g. R${examplePrice} with ${left} of ${total} sessions left → R${v}`;
})()
: null;
return (
<div className="space-y-5">
{multi && (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-brand-600" /> Late joining</h3>
<Toggle id="late-join" checked={value.lateJoinEnabled} onChange={v => onChange({ lateJoinEnabled: v })} title="Let people join after the first session">
Without this, registration closes when the first session starts.
</Toggle>
{value.lateJoinEnabled && (
<div className="pl-6 space-y-3">
<label className="block text-xs text-gray-600">Registration stays open until the start of
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={value.lateJoinUntilKey} onChange={e => onChange({ lateJoinUntilKey: e.target.value })}>
<option value="">Choose a session…</option>
{active.slice(1).map((s, i) => (
<option key={s.key} value={s.key}>{s.label || `Session ${i + 2}`} — {s.startsAt ? formatSessionWhen(s) : "no date yet"}</option>
))}
</select>
</label>
<fieldset className="space-y-1.5">
<legend className="text-xs text-gray-600 mb-1">Price for late joiners (main tickets only)</legend>
{([
["full", "Full price"],
["prorata", "Pro-rata on the sessions left (rounded to the nearest R10)"],
["fixed", "A fixed late price"],
] as const).map(([v, label]) => (
<label key={v} className="flex items-center gap-2 text-sm">
<input type="radio" name="late-pricing" checked={value.latePricing === v} onChange={() => onChange({ latePricing: v })} /> {label}
</label>
))}
{value.latePricing === "prorata" && example && <p className="text-xs text-gray-500 pl-6">{example}</p>}
{value.latePricing === "fixed" && (
<div className="pl-6">
<input type="number" min={0} step="1" className="w-32 border rounded px-2 py-1.5 text-sm" placeholder="R" value={value.lateFixedPrice} onChange={e => onChange({ lateFixedPrice: e.target.value })} />
<p className="text-[10px] text-gray-400 mt-0.5">Only ever lowers the price — anyone whose normal price is already lower keeps it.</p>
</div>
)}
</fieldset>
</div>
)}
</section>
)}
{multi && (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Check-in</h3>
{([
["whole_day", "Whole day", "Any time on the session's date — handy if someone was let in without being scanned."],
["session_only", "Session time only", "From 2 hours before the session starts until it ends."],
] as const).map(([v, title, hint]) => (
<label key={v} className="flex items-start gap-2 text-sm">
<input type="radio" name="checkin-window" className="mt-1" checked={value.checkInWindow === v} onChange={() => onChange({ checkInWindow: v })} />
<span><span className="font-medium">{title}</span><span className="block text-xs text-gray-500">{hint}</span></span>
</label>
))}
</section>
)}
{multi && (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Session reminders</h3>
<Toggle id="reminders" checked={value.remindersEnabled} onChange={v => onChange({ remindersEnabled: v })} title="Remind ticket holders before each session" />
{value.remindersEnabled && (
<div className="pl-6 grid sm:grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-xs text-gray-600">When</div>
{REMINDER_OPTIONS.map(o => (
<label key={o.minutes} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value.reminderOffsets.includes(o.minutes)} onChange={e => onChange({ reminderOffsets: e.target.checked ? [...value.reminderOffsets, o.minutes] : value.reminderOffsets.filter(x => x !== o.minutes) })} /> {o.label}
</label>
))}
</div>
<div className="space-y-1">
<div className="text-xs text-gray-600">How</div>
{(["email", "whatsapp"] as const).map(c => (
<label key={c} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value.reminderChannels.includes(c)} onChange={e => onChange({ reminderChannels: e.target.checked ? [...value.reminderChannels, c] : value.reminderChannels.filter(x => x !== c) })} /> {c === "email" ? "Email" : "WhatsApp"}
</label>
))}
<p className="text-[10px] text-gray-400">Each person&apos;s notification preference still applies.</p>
</div>
</div>
)}
</section>
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-1.5"><FileText className="w-4 h-4 text-brand-600" /> Certificates</h3>
<Toggle id="certificates" checked={value.certificatesEnabled} onChange={v => onChange({ certificatesEnabled: v })} title="Give completion certificates">
Issued automatically when the event ends to everyone who meets the rule below. You can also issue them from the attendance register.
</Toggle>
{value.certificatesEnabled && (
<div className="pl-6 space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{CERTIFICATE_TEMPLATES.map(t => (
<button key={t.id} type="button" onClick={() => setCert({ template: t.id as CertificateTemplate })}
className={`text-left p-2 rounded-lg border ${cert.template === t.id ? "border-brand-500 ring-2 ring-brand-200 bg-brand-50" : "border-gray-200 hover:bg-gray-50"}`}>
<span className="block text-sm font-medium">{t.name}</span>
<span className="block text-[11px] text-gray-500">{t.blurb}</span>
</button>
))}
</div>
<label className="block text-xs text-gray-600">Title
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={cert.title} onChange={e => setCert({ title: e.target.value })} />
</label>
<label className="block text-xs text-gray-600">Wording
<textarea rows={3} className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={cert.body} onChange={e => setCert({ body: e.target.value })} />
<span className="block text-[10px] text-gray-400 mt-0.5">Fills in: {"{name}"}, {"{event}"}, {"{sessionsAttended}"}, {"{totalSessions}"}, {"{endDate}"}</span>
</label>
<div className="grid sm:grid-cols-2 gap-2">
<label className="text-xs text-gray-600">Signed by (optional)
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" placeholder="e.g. Pastor John" value={cert.signatureName || ""} onChange={e => setCert({ signatureName: e.target.value })} />
</label>
<label className="text-xs text-gray-600">Their role (optional)
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" placeholder="e.g. Senior Pastor" value={cert.signatureRole || ""} onChange={e => setCert({ signatureRole: e.target.value })} />
</label>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600">
Signature image (optional):
{uploadButton(url => setCert({ signatureImage: url }))}
{cert.signatureImage && <button type="button" className="text-red-600 hover:underline" onClick={() => setCert({ signatureImage: "" })}>Remove</button>}
</div>
{multi ? (
<fieldset className="space-y-1.5">
<legend className="text-xs text-gray-600 mb-1">Who gets one</legend>
<label className="flex items-center gap-2 text-sm"><input type="radio" name="cert-rule" checked={cert.rule.type === "all"} onChange={() => setCert({ rule: { type: "all", value: null } })} /> Attended every session</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="cert-rule" checked={cert.rule.type === "count"} onChange={() => setCert({ rule: { type: "count", value: Math.max(1, total - 1) } })} /> Attended at least
<input type="number" min={1} className="w-16 border rounded px-2 py-0.5 text-sm" disabled={cert.rule.type !== "count"} value={cert.rule.type === "count" ? cert.rule.value ?? 1 : ""} onChange={e => setCert({ rule: { type: "count", value: Math.max(1, parseInt(e.target.value) || 1) } })} /> sessions
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="cert-rule" checked={cert.rule.type === "percent"} onChange={() => setCert({ rule: { type: "percent", value: 80 } })} /> Attended at least
<input type="number" min={1} max={100} className="w-16 border rounded px-2 py-0.5 text-sm" disabled={cert.rule.type !== "percent"} value={cert.rule.type === "percent" ? cert.rule.value ?? 80 : ""} onChange={e => setCert({ rule: { type: "percent", value: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) } })} /> % of sessions
</label>
</fieldset>
) : (
<p className="text-xs text-gray-500">Everyone who was checked in gets one.</p>
)}
<Toggle id="cert-send" checked={!!cert.sendOnIssue} onChange={v => setCert({ sendOnIssue: v })} title="Send certificates automatically">
Email/WhatsApp each certificate when it&apos;s issued. Otherwise people download theirs from My Events, and you can send them from the attendance register.
</Toggle>
<div>
<button type="button" disabled={previewing} onClick={async () => {
setPreviewing(true); setPreviewError(null);
try { await onPreview(); } catch (e) { setPreviewError(e instanceof Error ? e.message : "Couldn't make the preview"); } finally { setPreviewing(false); }
}} className="px-3 py-1.5 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
{previewing ? "Making preview…" : "Preview certificate"}
</button>
{previewError && <p className="text-xs text-red-600 mt-1">{previewError}</p>}
</div>
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import React from "react";
import type { Event } from "@/types";
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
// Tick boxes for the sessions of a "pick sessions" ticket. Full sessions (or ones with
// fewer places than `quantity`) can't be ticked.
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice }: {
event: Pick<Event, "sessions">;
option: { id: string; sessionAvailability?: Record<string, number> };
selected: string[];
onChange: (ids: string[]) => void;
quantity?: number;
staff?: boolean;
unitPrice?: number;
}) {
const sessions = bookableSessions(event, option, staff);
if (sessions.length === 0) return <p className="text-xs text-gray-500 mt-2">No sessions left to book.</p>;
const toggle = (id: string) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
const allOpen = sessions.filter(s => s.left === null || s.left >= Math.max(1, quantity)).map(s => s.id);
return (
<div className="mt-2 space-y-1" role="group" aria-label="Choose sessions">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Choose your sessions{unitPrice !== undefined ? ` — R${unitPrice.toFixed(2)} each` : ""}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => onChange(selected.length === allOpen.length ? [] : allOpen)}>
{selected.length === allOpen.length && allOpen.length > 0 ? "Clear" : "Select all"}
</button>
</div>
{sessions.map(s => {
const full = s.left !== null && s.left < Math.max(1, quantity);
return (
<label key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{sessionLabel(s, event.sessions)}</span>
<span className="text-gray-500">{formatSessionWhen(s)}</span>
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
</label>
);
})}
</div>
);
}
@@ -0,0 +1,39 @@
import { CalendarDays, MapPin } from "lucide-react";
import type { EventSession } from "@/types";
import { activeSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
// The schedule of a multi-day event on its public page: past sessions greyed out,
// cancelled ones struck through, and the next one highlighted.
export function SessionSchedule({ sessions, defaultLocation }: { sessions: EventSession[]; defaultLocation?: string | null }) {
const now = Date.now();
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
const next = activeSessions(sessions).find(s => new Date(s.endsAt).getTime() > now);
const count = activeSessions(sessions).length;
return (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<CalendarDays className="w-4 h-4 text-gray-500" />
<h2 className="text-base font-semibold text-gray-900">Schedule</h2>
<span className="text-xs text-gray-500">· {count} session{count === 1 ? "" : "s"}, one ticket for all</span>
</div>
<ol className="space-y-1.5">
{sorted.map((s) => {
const cancelled = !!(s.cancelled || s.cancelledAt);
const past = !cancelled && new Date(s.endsAt).getTime() <= now;
const isNext = next?.id === s.id;
const place = s.location && s.location !== defaultLocation ? s.location : null;
return (
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-400" : "text-gray-700"}`}>
<span className={`font-medium ${cancelled ? "line-through text-gray-400" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
<span className={cancelled ? "line-through text-gray-400" : ""}>{formatSessionWhen(s)}</span>
{cancelled && <span className="text-xs font-medium text-amber-700">Cancelled</span>}
{isNext && <span className="text-xs font-medium text-brand-700">Next</span>}
{place && !cancelled && <span className="flex items-center gap-1 text-xs text-gray-500"><MapPin className="w-3 h-3" />{place}</span>}
</li>
);
})}
</ol>
</div>
);
}
@@ -73,7 +73,7 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
{lock ? (
<>
<span className="text-gray-400">·</span>
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}</span>
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}{lock.sessionLabel ? ` · ${lock.sessionLabel}` : ""}</span>
<span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span>
<span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span>
<div className="ml-auto flex gap-2">
@@ -88,8 +88,13 @@ export function useOfflineScanning(token: string | null) {
const created = await apiFetch<{ id: string }>("/api/scan-locks", {
method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
});
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[] }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt };
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[]; session?: { id: string; label: string } | null; noSessionToday?: boolean }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
// Multi-day events: the downloaded list is for today's session, so there must be one.
if (snap.noSessionToday) {
await apiFetch(`/api/scan-locks/${created.id}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } }).catch(() => {});
throw new Error("There's no session of this event open for check-in today, so offline mode can't start.");
}
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt, sessionLabel: snap.session?.label || null };
await startOffline(next, snap.tickets);
setLock(next);
return snap.tickets.length;
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft, Award, CalendarDays } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -60,6 +60,12 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
</GuideItem>
<GuideItem icon={CalendarDays} title="Events with several sessions" tone="brand">
For a course or event that runs on several dates, your one ticket works for every session — bring the same QR code each time. The event page shows the full schedule.
</GuideItem>
<GuideItem icon={Award} title="Certificates" tone="amber">
If an event gives completion certificates, yours appears under My Certificates once it&apos;s issued — usually after the last session. Download it as a PDF; it has a code anyone can check.
</GuideItem>
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
Can&apos;t make it? Tap &quot;Transfer&quot; to offer your ticket to someone else before the event. Type their name and email and/or phone number — there&apos;s no searching for people — and we send them a link to accept it. Until they accept, the ticket is still yours (you&apos;ll see &quot;Waiting for … to accept&quot;, and can cancel). Once they accept, they get their own ticket with a new QR code, and the old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
</GuideItem>
+6
View File
@@ -25,6 +25,7 @@ import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorBundlesHelpContent } from "./supervisor-bundles";
import { supervisorConsentHelpContent } from "./supervisor-consent";
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
import { supervisorAttendanceHelpContent } from "./supervisor-attendance";
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
@@ -173,6 +174,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/waitlist",
content: supervisorWaitlistHelpContent,
},
{
slug: "supervisor-attendance",
matches: pathname => pathname === "/dashboard/supervisor/attendance",
content: supervisorAttendanceHelpContent,
},
{
slug: "supervisor-consent",
matches: pathname => pathname === "/dashboard/supervisor/consent",
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source (short links and utm_source links) and device. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+24 -2
View File
@@ -1,9 +1,9 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for Site Settings (/dashboard/admin/settings), matching its six tabs. */
/** Help content for Site Settings (/dashboard/admin/settings). */
export const siteSettingsHelpContent: HelpContent = {
title: "Site Settings help",
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
@@ -25,6 +25,9 @@ export const siteSettingsHelpContent: HelpContent = {
<GuideItem icon={Building2} title="Site URL" tone="gray">
The public URL of this site, used in email links like password resets and ticket delivery.
</GuideItem>
<GuideItem icon={Building2} title="Google Search Console verification" tone="gray">
Proves to Google that you own this site, so you can see search traffic and submit <code>/sitemap.xml</code> in Search Console. Add the site there as a URL-prefix property (e.g. <code>https://events.yourchurch.org/</code>), pick the <strong>HTML tag</strong> method, paste the tag here and save, then click Verify. Each site has its own code. Google finds your events without this; it only unlocks the Search Console reports.
</GuideItem>
</div>
</div>
),
@@ -93,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
</div>
),
},
{
key: "jobs",
label: "Background jobs",
icon: Timer,
content: (
<div className="space-y-4">
<p>Tasks the server runs on a schedule: the daily summary email, waitlist offers, feedback forms, session reminders and certificates, consent reminders, analytics, the nightly backup and temp-file cleanup.</p>
<div className="space-y-4">
<GuideItem icon={Timer} title="Schedule and history" tone="brand">
Each job shows when it runs and when it runs next. Click a job&apos;s last result to see its last 10 runs — when they started and finished, and what they did (or the error). A failed run is retried twice.
</GuideItem>
<GuideItem icon={Play} title="Run now" tone="green">
Runs a job straight away, e.g. to send a feedback form without waiting. It&apos;s recorded in the audit log.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Jobs run once per schedule even when more than one server process is running. Schedules and on/off switches are set by the server&apos;s environment variables.</p>
</div>
),
},
],
};
@@ -1,5 +1,5 @@
import React from "react";
import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff } from "lucide-react";
import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff, CalendarDays } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -22,6 +22,9 @@ export const staffTicketScanningHelpContent: HelpContent = {
<GuideItem icon={WifiOff} title="Offline mode" tone="amber">
Worried about the venue Wi-Fi? While you&apos;re online, pick the event and section and tap &quot;Enable offline mode&quot;. The section is locked to your device and its tickets are downloaded, so you can keep scanning with no connection. Scans sync automatically when you&apos;re back online. Tickets bought after the download won&apos;t be on your list — send those people to the door desk. Tap &quot;Release section&quot; when you&apos;re done. Tip: add this page to your home screen so it opens offline.
</GuideItem>
<GuideItem icon={CalendarDays} title="Multi-session events" tone="brand">
A ticket for an event with several sessions is checked in once per session. The scanner uses today&apos;s session automatically (pick another under &quot;Session&quot; if you need to). Scanning the same ticket again for the same session shows &quot;Already checked in for …&quot;. Depending on the event, check-in is open the whole day of a session or only around its start time. Offline mode downloads the list for today&apos;s session.
</GuideItem>
<GuideItem icon={ShieldAlert} title="Parental consent" tone="rose">
On youth events, the confirm screen warns when a parent hasn&apos;t given consent yet (or declined). That person can&apos;t be checked in; a supervisor can override, and the override is recorded in the audit log.
</GuideItem>
@@ -0,0 +1,61 @@
import React from "react";
import { ClipboardCheck, MousePointerClick, Filter, FileSpreadsheet, Award, Send, Ban, ShieldCheck } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/attendance. */
export const supervisorAttendanceHelpContent: HelpContent = {
title: "Attendance",
subtitle: "Who came to each session, and completion certificates.",
tabs: [
{
key: "register",
label: "The register",
icon: ClipboardCheck,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={ClipboardCheck} title="One row per person" tone="brand">
Every main ticket is a row, with a column for each session of a multi-day event (one &quot;Checked in&quot; column for a single-date event). A tick means they were scanned in for that session; a number is how many places of a group ticket were.
</GuideItem>
<GuideItem icon={MousePointerClick} title="Fixing a session" tone="green">
Supervisors can click a box to mark someone present (e.g. they were let in without a scan) or absent. Each change is recorded in the audit log. Closed events can&apos;t be changed.
</GuideItem>
<GuideItem icon={Filter} title="Finding people" tone="violet">
Search by name, email or phone. Filter to who completed, who didn&apos;t, or who missed the last two sessions — handy for a follow-up call.
</GuideItem>
<GuideItem icon={FileSpreadsheet} title="Export" tone="amber">
Download the register as Excel (with contact details) or a printable PDF. Cancelled sessions are left out.
</GuideItem>
</div>
</div>
),
},
{
key: "certificates",
label: "Certificates",
icon: Award,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Award} title="Turning them on" tone="brand">
In the event wizard&apos;s Attendance step: pick one of four designs, the wording, who signs it, and who qualifies (every session, at least N sessions, or at least N%). They&apos;re issued automatically when the event ends.
</GuideItem>
<GuideItem icon={MousePointerClick} title="Issuing now" tone="green">
&quot;Issue now&quot; gives one to everyone who already qualifies. Use &quot;Issue anyway&quot; on a row to give one to someone who doesn&apos;t meet the rule.
</GuideItem>
<GuideItem icon={Send} title="Sending" tone="violet">
People can always download theirs from My Events. &quot;Send unsent&quot; emails or WhatsApps the ones not sent yet (as each person prefers); the event can also send them automatically.
</GuideItem>
<GuideItem icon={Ban} title="Revoking" tone="rose">
A revoked certificate stops showing as valid and is never issued again automatically. You can re-issue it by hand.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Checking one" tone="amber">
Each certificate has a code and QR code. Anyone can check it at /certificates/verify/&lt;code&gt;, which shows only the name, event and dates.
</GuideItem>
</div>
</div>
),
},
],
};
@@ -20,7 +20,7 @@ export const supervisorEventOptionsHelpContent: HelpContent = {
<p>Pick an event, then edit each option&apos;s name, price, and &quot;Main ticket&quot; flag directly in the list — changes save when you click away from the field. Use the form below the list to add a new option.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird tiers" tone="amber">
Expand &quot;Manage early-bird tiers&quot; on an option to add deadline-and-price rows that apply automatically before the cutoff.
Expand &quot;Manage early-bird tiers&quot; on an option to add deadline-and-price rows that apply automatically before the cutoff. While a tier is running (before its deadline, with places left), the event&apos;s card on the home page and events list shows an &quot;Early bird&quot; ribbon in the top-right corner.
</GuideItem>
</div>
</div>
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart } from "lucide-react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -34,6 +34,12 @@ export const supervisorEventsHelpContent: HelpContent = {
<GuideItem icon={Calendar} title="Basic details" tone="brand">
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
</GuideItem>
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
Choose &quot;Multiple sessions&quot; for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label (&quot;Week 1 — …&quot;), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
</GuideItem>
<GuideItem icon={CalendarDays} title="Pick sessions" tone="violet">
For day passes or a drop-in class, set &quot;How people book&quot; to &quot;Pick sessions&quot;, give sessions an optional capacity, and in Items &amp; Pricing set a ticket to &quot;Pick sessions (price per session)&quot;. People tick the sessions they want; each one is its own ticket. Whole-course tickets can still be sold too, until the course starts.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
</GuideItem>
@@ -55,6 +61,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet">
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn&apos;t answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form.
</GuideItem>
<GuideItem icon={ClipboardCheck} title="Attendance" tone="green">
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. &quot;Preview certificate&quot; shows a sample.
</GuideItem>
</div>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p>
</div>
@@ -18,6 +18,7 @@ export type SiteSettings = {
logo_url?: string;
favicon_url?: string;
setup_complete?: string;
google_site_verification?: string;
// Legal pages
legal_operator_name?: string;
legal_io_name?: string;
+36 -2
View File
@@ -56,11 +56,45 @@ export function analyticsSessionId(): string | undefined {
}
}
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
// Referring sites we name ourselves; any other site is recorded by its hostname.
const REFERRER_SOURCES: [RegExp, string][] = [
[/(^|\.)(facebook\.com|fb\.com|fb\.me)$/, "facebook"],
[/(^|\.)instagram\.com$/, "instagram"],
[/(^|\.)(whatsapp\.com|wa\.me)$/, "whatsapp"],
[/(^|\.)google\.[a-z.]+$/, "google"],
[/(^|\.)bing\.com$/, "bing"],
[/(^|\.)duckduckgo\.com$/, "duckduckgo"],
[/(^|\.)yahoo\.com$/, "yahoo"],
[/(^|\.)(twitter\.com|x\.com|t\.co)$/, "x"],
[/(^|\.)(linkedin\.com|lnkd\.in)$/, "linkedin"],
[/(^|\.)(youtube\.com|youtu\.be)$/, "youtube"],
[/(^|\.)tiktok\.com$/, "tiktok"],
];
/** The referring site as a source label; undefined when there's none or it's this site. */
function sourceFromReferrer(): string | undefined {
try {
if (!document.referrer) return undefined;
const host = new URL(document.referrer).hostname.toLowerCase();
if (!host || host === window.location.hostname.toLowerCase()) return undefined;
for (const [re, label] of REFERRER_SOURCES) if (re.test(host)) return label;
return host.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* Where the visitor came from: ?utm_source=… or ?src=<short-link alias> when the link
* says so, else the referring site (Facebook, Google, …), else Facebook's/Google's click
* ids. WhatsApp sends no referrer, so it's only known from a tagged link.
*/
function sourceFromUrl(): string | undefined {
try {
const p = new URLSearchParams(window.location.search);
return p.get("utm_source") || p.get("src") || undefined;
return p.get("utm_source") || p.get("src") || sourceFromReferrer()
|| (p.has("fbclid") ? "facebook" : undefined)
|| (p.has("gclid") ? "google" : undefined);
} catch {
return undefined;
}
+85
View File
@@ -0,0 +1,85 @@
// schema.org Event data for an event page, so Google can show the event with its date,
// venue and ticket link (event rich results / "events near me").
// Checked with https://search.google.com/test/rich-results
type SchemaOption = {
price: number;
stockLimit?: number;
availableCount?: number;
earlyBirdTiers?: { deadline: string; price: number }[];
variants?: { price: number | null; stockLimit?: number; availableCount?: number }[];
};
export type SchemaEvent = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
goLiveAt?: string;
picture?: string;
location?: string | null;
requiresRegistration?: boolean;
eventOptions?: SchemaOption[];
};
/** Today's price of each ticket (early-bird when one is open, else the variant/base price). */
function currentPrices(options: SchemaOption[], now: Date): number[] {
const prices: number[] = [];
for (const o of options) {
if (o.variants && o.variants.length > 0) {
for (const v of o.variants) prices.push(v.price ?? o.price);
continue;
}
const open = (o.earlyBirdTiers || []).filter((t) => now < new Date(t.deadline)).map((t) => t.price);
prices.push(open.length > 0 ? Math.min(...open) : o.price);
}
return prices.filter((p) => Number.isFinite(p));
}
export function eventJsonLd(
event: SchemaEvent,
{ pageUrl, imageUrl, organizerName, siteUrl }: { pageUrl: string; imageUrl: string | null; organizerName: string; siteUrl: string },
) {
const now = new Date();
const options = event.eventOptions || [];
const limited = options.filter((o) => (o.stockLimit ?? 0) > 0);
const soldOut = limited.length > 0 && limited.every((o) => o.availableCount !== undefined && o.availableCount <= 0);
// Contact-only events (requiresRegistration false) have no tickets to offer.
let offers: Record<string, unknown> | undefined;
if (event.requiresRegistration !== false) {
const prices = options.length > 0 ? currentPrices(options, now) : [0];
const low = Math.min(...prices);
const high = Math.max(...prices);
offers = {
"@type": low === high ? "Offer" : "AggregateOffer",
...(low === high ? { price: low.toFixed(2) } : { lowPrice: low.toFixed(2), highPrice: high.toFixed(2) }),
priceCurrency: "ZAR",
availability: soldOut ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
url: `${siteUrl}/register/${event.id}`,
...(event.goLiveAt ? { validFrom: new Date(event.goLiveAt).toISOString() } : {}),
};
}
return {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
...(event.description ? { description: event.description.slice(0, 5000) } : {}),
startDate: new Date(event.startDate).toISOString(),
endDate: new Date(event.endDate).toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
...(event.location ? { location: { "@type": "Place", name: event.location, address: event.location } } : {}),
...(imageUrl ? { image: [imageUrl] } : {}),
url: pageUrl,
organizer: { "@type": "Organization", name: organizerName, url: siteUrl },
...(offers ? { offers } : {}),
};
}
/** JSON for a <script type="application/ld+json">, safe against "</script>" in event text. */
export function jsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+34
View File
@@ -191,3 +191,37 @@ export function mailtoReport(subject: string, body: string) {
const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = url;
}
// Fetches a file (PDF, Excel…) from the API with the user's token.
export async function fetchApiFile(apiBase: string, authToken: string, path: string, init: { method?: string; body?: unknown } = {}): Promise<Blob> {
const res = await fetch(`${apiBase}${path}`, {
method: init.method || 'GET',
headers: {
'Authorization': `Bearer ${authToken}`,
...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = text;
try { message = JSON.parse(text)?.message || text; } catch { /* not JSON */ }
throw new Error(message || `Download failed (${res.status})`);
}
return res.blob();
}
export function saveBlob(blob: Blob, filename: string) {
const dl = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
dl.href = objUrl;
dl.download = filename;
dl.click();
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
export function openBlob(blob: Blob) {
const objUrl = URL.createObjectURL(blob);
window.open(objUrl, '_blank');
setTimeout(() => URL.revokeObjectURL(objUrl), 60000);
}
+2
View File
@@ -29,6 +29,8 @@ export type OfflineLock = {
sectionId: string | null;
sectionName: string | null;
snapshotAt: string;
// Multi-day events: the session the downloaded ticket list is for.
sessionLabel?: string | null;
};
const DB_NAME = "hope-events-offline-scan";
+24
View File
@@ -0,0 +1,24 @@
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { API_BASE } from "@/lib/api";
/**
* Server-side settings fetch, shared by the root layout (generateMetadata and
* RootLayout) and server pages that need branding, e.g. the event page's organizer.
* Every caller uses the identical URL + options, so Next's per-request fetch
* memoization dedupes them into a single network call. Degrades to `{}`
* (static defaults everywhere downstream) if the backend is unreachable at
* request time — intentional, not a bug: branding just isn't critical enough
* to fail the whole page render over.
*/
export async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
+194
View File
@@ -0,0 +1,194 @@
import type { Event, EventSession, LateJoinInfo } from "@/types";
// Multi-day events — browser-side helpers. The rules live in backend utils/sessions.js;
// latePrice below mirrors it so prices shown match what the server charges.
export type SessionPattern = {
frequency: "daily" | "weekly";
interval: number; // every N days/weeks
weekdays: number[]; // weekly only: 0 = Sunday … 6 = Saturday
startDate: string; // YYYY-MM-DD (local)
startTime: string; // HH:MM
endTime: string; // HH:MM (an end before the start runs past midnight)
endsBy: "count" | "until";
count: number;
until: string; // YYYY-MM-DD (local)
};
export type SessionDraft = {
id?: string;
label: string;
startsAt: string; // datetime-local value (YYYY-MM-DDTHH:MM, local)
endsAt: string;
location: string;
cancelled: boolean;
capacity: string; // "pick sessions" events; blank = no limit
hasCheckIns?: boolean;
};
const MAX_GENERATED = 200;
function localDT(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function atTime(day: Date, hhmm: string): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(day);
d.setHours(h || 0, m || 0, 0, 0);
return d;
}
/** Turns a repeat pattern into session rows (local time). */
export function generateSessions(p: SessionPattern): SessionDraft[] {
const [y, mo, d] = p.startDate.split("-").map(Number);
if (!y || !mo || !d || !p.startTime || !p.endTime) return [];
const first = new Date(y, mo - 1, d);
const until = p.endsBy === "until" && p.until ? (() => { const [uy, um, ud] = p.until.split("-").map(Number); return new Date(uy, um - 1, ud, 23, 59); })() : null;
const limit = p.endsBy === "count" ? Math.min(Math.max(1, p.count || 1), MAX_GENERATED) : MAX_GENERATED;
const interval = Math.max(1, p.interval || 1);
const weekdays = p.frequency === "weekly" ? (p.weekdays.length ? [...p.weekdays].sort() : [first.getDay()]) : [];
const out: SessionDraft[] = [];
const push = (day: Date) => {
const start = atTime(day, p.startTime);
let end = atTime(day, p.endTime);
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
};
if (p.frequency === "daily") {
for (let i = 0, day = new Date(first); out.length < limit; i++, day.setDate(day.getDate() + interval)) {
if (until && day > until) break;
push(day);
if (i > 5000) break;
}
return out;
}
// Weekly: walk week by week from the week containing the start date (weeks start Sunday).
const weekStart = new Date(first);
weekStart.setDate(first.getDate() - first.getDay());
for (let w = 0; out.length < limit && w < 1000; w += interval) {
for (const wd of weekdays) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + w * 7 + wd);
if (day < first) continue;
if (until && day > until) return out;
if (out.length >= limit) return out;
push(day);
}
}
return out;
}
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
}
/** Sessions that aren't cancelled, earliest first. */
export function activeSessions<T extends { startsAt: string; cancelled?: boolean; cancelledAt?: string | null }>(sessions: T[] | undefined | null): T[] {
return (sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
}
/** "Week 1" — the session's label, else its position among active sessions. */
export function sessionLabel(session: EventSession, sessions: EventSession[] | undefined | null): string {
if (session.label) return session.label;
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
return `Session ${idx >= 0 ? idx + 1 : ""}`.trim();
}
const dayFmt = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
const timeFmt = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
/** "Wed 4 Nov, 18:30–20:30" */
export function formatSessionWhen(s: { startsAt: string; endsAt: string }): string {
const start = new Date(s.startsAt);
const end = new Date(s.endsAt);
const sameDay = start.toDateString() === end.toDateString();
return sameDay
? `${dayFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`
: `${dayFmt.format(start)} ${timeFmt.format(start)} – ${dayFmt.format(end)} ${timeFmt.format(end)}`;
}
/** "5 sessions · Wed 4 Nov – Wed 2 Dec" for event cards; null for single-date events. */
export function scheduleSummary(event: Pick<Event, "scheduleType" | "sessions">): string | null {
if (event.scheduleType !== "sessions") return null;
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
const first = dayFmt.format(new Date(active[0].startsAt));
const last = dayFmt.format(new Date(active[active.length - 1].startsAt));
return active.length === 1 ? `1 session · ${first}` : `${active.length} sessions · ${first} – ${last}`;
}
/** When public registration closes (falls back for older API responses). */
export function registrationClosesAt(event: Pick<Event, "registrationClosesAt" | "registrationDeadline" | "endDate">): Date {
if (event.registrationClosesAt) return new Date(event.registrationClosesAt);
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
return deadline && deadline < end ? deadline : end;
}
/** A main ticket's price for a late joiner — same rule as the backend. */
export function latePrice(price: number, info: LateJoinInfo | null | undefined): number {
if (!info || !info.active || !(price > 0)) return price;
if (info.mode === "fixed") return Math.min(price, Math.max(0, Number(info.fixedPrice)));
if (info.mode === "prorata") {
if (!info.totalSessions) return price;
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
return Math.min(price, Math.max(10, rounded));
}
return price;
}
export const REMINDER_OPTIONS = [
{ minutes: 60, label: "1 hour before" },
{ minutes: 180, label: "3 hours before" },
{ minutes: 1440, label: "1 day before" },
] as const;
export const CERTIFICATE_TEMPLATES = [
{ id: "classic", name: "Classic", blurb: "Double border, serif type" },
{ id: "modern", name: "Modern", blurb: "Clean, with a colour bar" },
{ id: "church", name: "Church", blurb: "Logo-led and soft" },
{ id: "kids", name: "Kids", blurb: "Bright and playful" },
] as const;
export const DEFAULT_CERTIFICATE_BODY = "This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.";
/** Label of the session open for check-in today (whole-day rule, browser time), or null. */
export function currentSessionLabel(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): string | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
return s ? sessionLabel(s, event.sessions) : null;
}
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const now = Date.now();
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
/** Whether an option is bought per session (only in "pick sessions" events). */
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
}
/**
* Sessions a pick option can still be booked for, with places left (null = no limit).
* Public: before the session starts; staff: until it ends.
*/
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
const now = Date.now();
return activeSessions(event.sessions)
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
.map(s => {
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
return { ...s, left: limits.length ? Math.min(...limits) : null };
});
}
+7 -1
View File
@@ -1,5 +1,6 @@
import type { Ticket as TicketRecord } from "@/types";
import { formatDate } from "@/lib/date";
import { formatSessionWhen, scheduleSummary } from "@/lib/sessions";
// One ticket design for every printed ticket (user dashboard, staff event tickets, at the
// door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it.
@@ -17,7 +18,12 @@ export function ticketTypeLabel(t: TicketRecord): string {
/** A ticket card. `buyerName` adds a "Purchased by" line. */
export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string {
const eventTitle = t.event?.title || t.eventId || "Event";
const dateStr = t.event?.startDate ? formatDate(t.event.startDate) : "";
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session;
// a picked-session ticket shows its own session.
const session = t.registrationOption?.session;
const dateStr = session
? `${session.label || "Session"} · ${formatSessionWhen(session)}`
: (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`;
return `
<div class="ticket">
+105 -1
View File
@@ -73,6 +73,10 @@ export type EventOption = {
earlyBirdTiers?: EarlyBirdTier[];
createdAt?: string;
updatedAt?: string;
// "Pick sessions" events: `pick` options are bought per session (price per session).
sessionMode?: "all" | "pick";
// Pick options with a stock limit: places left per session id.
sessionAvailability?: Record<string, number>;
};
export type FormFieldType =
@@ -150,6 +154,103 @@ export type Event = {
closedById?: string | null;
reopenedAt?: string | null;
reopenedById?: string | null;
// Multi-day events (see backend utils/sessions.js). startDate/endDate follow the sessions.
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
nextSession?: EventSession | null;
// When public registration closes: the deadline, the first session (or session N with
// late joining), or the end date — whichever applies.
registrationClosesAt?: string;
// "Pick sessions" events: when whole-course tickets stop selling.
wholeCourseClosesAt?: string | null;
lateJoinEnabled?: boolean;
lateJoinUntilSessionId?: string | null;
latePricing?: "full" | "prorata" | "fixed";
lateFixedPrice?: number | null;
lateJoin?: LateJoinInfo | null;
checkInWindow?: "whole_day" | "session_only";
sessionReminders?: { offsets: number[]; channels: ("email" | "whatsapp")[] } | null;
certificatesEnabled?: boolean;
certificateConfig?: CertificateConfig | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
isSoldOut?: boolean;
};
export type EventSession = {
id: string;
eventId?: string;
label?: string | null;
startsAt: string;
endsAt: string;
location?: string | null;
cancelledAt?: string | null;
cancelled?: boolean;
order?: number;
// "Pick sessions" events: optional limit on people, and (single-event responses) how
// many are booked and how many places are left (null = no limit).
capacity?: number | null;
booked?: number;
placesLeft?: number | null;
};
export type LateJoinInfo = {
active: boolean;
mode: "full" | "prorata" | "fixed";
sessionsLeft: number;
totalSessions: number;
fixedPrice: number | null;
};
export type CertificateTemplate = "classic" | "modern" | "church" | "kids";
export type CertificateConfig = {
template: CertificateTemplate;
title: string;
body: string;
signatureName?: string;
signatureRole?: string;
signatureImage?: string;
rule: { type: "all" | "count" | "percent"; value: number | null };
sendOnIssue?: boolean;
};
// GET /api/attendance/:eventId
export type AttendanceColumn = { id: string; label: string; startsAt: string; endsAt: string; cancelled: boolean };
export type AttendanceRow = {
ticketId: string;
// Picked-session bookings: the sessions booked (null = all) and the ticket for each.
bookedSessions?: string[] | null;
sessionTickets?: Record<string, string> | null;
name: string;
bookedBy: string;
email: string;
phone: string;
option: string;
variant: string | null;
quantity: number;
checkIns: Record<string, number>;
attended: number;
total: number;
completed: boolean;
certificate: { id: string; code: string; issuedAt: string; revokedAt: string | null; manual: boolean; emailedAt: string | null } | null;
};
export type AttendanceRegister = {
event: Pick<Event, "id" | "title" | "scheduleType" | "startDate" | "endDate" | "cashupStatus" | "certificatesEnabled"> & { certificateConfig: CertificateConfig };
columns: AttendanceColumn[];
rows: AttendanceRow[];
totals: Record<string, number>;
};
// GET /api/attendance/certificates/mine
export type MyCertificate = {
id: string;
code: string;
name: string;
issuedAt: string;
sessionsAttended: number;
totalSessions: number;
event: { id: string; title: string; endDate: string };
};
export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null };
@@ -371,6 +472,9 @@ export type RegistrationOption = {
appliedTierId?: string | null;
eventOption?: EventOption;
variant?: OptionVariant | null;
// Picked-session lines: the one session this line (and its ticket) is for.
sessionId?: string | null;
session?: EventSession | null;
// One entry per price the line was bought at (e.g. some before an early-bird tier expired).
tranches?: { id: string; quantity: number; priceSnapshot: number; appliedTierId?: string | null }[];
tickets?: { id: string; quantity?: number }[];
@@ -441,7 +545,7 @@ export type Ticket = {
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
event?: Event;
registrationOption?: RegistrationOption;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[];
};
export type Payment = {
+1 -1
View File
@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
"exclude": ["node_modules"]
}
+48 -2
View File
@@ -1,17 +1,34 @@
{
"name": "hope-events",
"version": "1.30.1",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.30.1",
"version": "1.35.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -175,6 +192,35 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.30.1",
"version": "1.35.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -9,13 +9,16 @@
"start:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate",
"test:e2e": "playwright test",
"test": "npm --prefix backend test"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
},
"overrides": {
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { e2eDatabaseUrl } = require("./e2e/support/db-url");
// End-to-end tests: their own backend (port 5100) and frontend (port 3100) against a
// separate database that is recreated and seeded on every run (e2e/support/prepare-db.js).
// Run with `npm run test:e2e`. In CI (CI=true) the frontend is built and started instead
// of run in dev mode.
const API_PORT = 5100;
const WEB_PORT = 3100;
const ci = !!process.env.CI;
const databaseUrl = e2eDatabaseUrl();
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
// One shared database, so tests run one after another.
workers: 1,
fullyParallel: false,
retries: ci ? 1 : 0,
timeout: 120_000,
expect: { timeout: 20_000 },
reporter: ci ? [["list"], ["html", { open: "never", outputFolder: "e2e/report" }]] : "list",
outputDir: "e2e/test-results",
use: {
baseURL: `http://localhost:${WEB_PORT}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
// Dev-mode pages compile on first visit.
navigationTimeout: 90_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: [
{
command: "node ../e2e/support/prepare-db.js && node src/index.js",
cwd: "backend",
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
timeout: 180_000,
reuseExistingServer: !ci,
stdout: "ignore",
stderr: "pipe",
env: {
PORT: String(API_PORT),
DATABASE_URL: databaseUrl,
E2E_DATABASE_URL: databaseUrl,
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
JOBS_ENABLED: "false",
SCHEDULED_EMAILS_ENABLED: "false",
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
TWO_FACTOR_REQUIRED_ROLES: "false",
},
},
{
command: ci ? `npm run build && npx next start -p ${WEB_PORT}` : `npx next dev -p ${WEB_PORT}`,
cwd: "frontend",
url: `http://localhost:${WEB_PORT}`,
timeout: 600_000,
reuseExistingServer: !ci,
stdout: "ignore",
stderr: "pipe",
env: {
NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`,
API_URL: `http://localhost:${API_PORT}`,
NEXT_DIST_DIR: ci ? ".next" : ".next-e2e",
NEXT_TELEMETRY_DISABLED: "1",
},
},
],
});
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Updates this install to a tagged release: back up the database, check out the tag,
# install, migrate, build, and reload PM2. If anything fails before the reload, the
# previous version keeps running and the checkout goes back to it.
#
# Usage (on the server, from the repo folder): bash scripts/deploy.sh v1.34.0
# CI runs this over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
set -euo pipefail
TAG="${1:?Usage: scripts/deploy.sh <tag>}"
cd "$(dirname "$0")/.."
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
echo "==> Deploying $TAG (currently $PREVIOUS)"
echo "==> Backing up the database"
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
rollback() {
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
git checkout -q "$PREVIOUS"
(cd backend && npm ci --silent) || true
(cd frontend && npm ci --silent && npm run build --silent) || true
}
trap rollback ERR
git fetch --tags --quiet origin
git checkout -q "$TAG"
echo "==> Backend: install and migrate"
(cd backend && npm ci && npm run prisma:deploy)
echo "==> Frontend: install and build"
(cd frontend && npm ci && npm run build)
trap - ERR
echo "==> Reloading PM2"
pm2 reload ecosystem.config.js --update-env
echo "==> Deployed $TAG"