Compare commits

..
149 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 2d4bc5c38d Roadmap: AI helpers as the 2.0 headline, and later AI stages
CI / Backend tests (push) Waiting to run
CI / Frontend lint, typecheck, build (push) Waiting to run
CI / End-to-end tests (push) Blocked by required conditions
CI / Deploy tagged release (push) Blocked by required conditions
- §15 AI helpers (2.0): one-button helpers (draft an event, summarise
  reports and feedback, draft messages, improve text) on Claude Haiku 5.5,
  called through the Hub, which holds the key, meters usage in rand and
  enforces a monthly cap per licence; admin usage view; POPIA notes
- §16 AI later stages (unversioned): Ask AI, help/navigation assistant,
  actions with confirmation, more helpers, long-term self-hosted AI
- Hub: AI cap as the first per-licence limit, AiUsage model; decisions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-24 14:36:25 +02:00
joshua f7a19f254a Merge branch 'feat/popia-retention'
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-23 20:26:01 +02:00
joshuaandClaude Opus 5.5 8c5e6bd23c Bump to 1.40.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>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 20:26:01 +02:00
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
  `retention` job: inactive accounts anonymised after 5 years with a
  30-day warning (on by default; never staff roles or people with an
  upcoming registration), form answers, login history, payer details in
  payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
  and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
  the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 20:25:58 +02:00
joshua 6eee10b610 Merge branch 'feat/popia-admin-tools'
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-23 19:11:23 +02:00
joshuaandClaude Opus 5.5 7b57fa4d04 Bump to 1.39.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>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 19:11:23 +02:00
joshuaandClaude Opus 5.5 d265047c84 POPIA toolkit part 2: privacy notice editor, operators, consent and access logs
- Admin -> Privacy (POPIA): edit the privacy notice (built-in text as the
  start, placeholders for org/IO/operators), drafts, preview, numbered
  versions; earlier versions readable on /legal/privacy?version=N
- "Significant change" versions are accepted once after login
  (NoticePrompt); sign-up records the version accepted
- Operator register (seeded with the services the app uses), listed in
  the notice via {operators}
- Consent log (news opt-ins, notice acceptances, parental consent) and
  access log (report exports, registers, certificates, group lists, form
  answers, consent/medical lists, backups, impersonation)
- Information Officer on the contact page
- Unit tests (privacyNotice.test.js), e2e (privacy-notice.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 19:11:20 +02:00
joshua 40c5c9cc1d Merge branch 'feat/popia-privacy-centre'
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-23 18:24:07 +02:00
joshuaandClaude Opus 5.5 261bc8a639 Bump to 1.38.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>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 18:24:07 +02:00
joshuaandClaude Opus 5.5 594cb35a85 POPIA toolkit part 1: privacy page, unsubscribe, WhatsApp STOP, data requests
- Privacy page: news opt-in per channel with history, download my data
  (JSON + PDF), correction and deletion requests
- Bulk messages are an Event update or News & announcements; news only
  reaches people who opted in, with unsubscribe links, one-click
  List-Unsubscribe headers and "Reply STOP" on WhatsApp
- WhatsApp STOP via WAWP's message webhook, checked against a secret in
  the webhook URL
- Sign-up opt-in box; existing accounts kept opted in and asked once
- Admin -> Data requests with 30-day due dates and a daily reminder job
- Deleting an account is a request; anonymising now covers attendees,
  form answers, consent details, payer details in provider payloads, etc.
- Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 18:13:47 +02:00
joshua be5ad620da Merge branch 'fix/deploy-script'
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-23 16:20:22 +02:00
joshuaandClaude Opus 5.5 96adf20cf0 Deploy script: roll back once (not inside subshells); docs; bump to 1.37.4
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 16:20:22 +02:00
joshuaandClaude Opus 5.5 a9c7a1129b Deploy: latest main without a tag; per-install PM2 config out of git
- scripts/deploy.sh with no tag deploys the latest main (detached
  origin/main); with a tag, that release.
- ecosystem.config.js is no longer in git (now ecosystem.config.example.js;
  the deploy creates the install's copy from it once). The deploy keeps the
  install's copy safe across checkouts and rollbacks, including releases
  that still tracked it.
- The script runs from a temp copy, so checking out a new deploy.sh can't
  change it mid-run; set -E so failures in functions still roll back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:15:29 +02:00
joshua d8eb9f22cc Merge branch 'fix/help-entities'
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-23 15:57:46 +02:00
joshuaandClaude Opus 5.5 4cae0fc672 Fix the production build: escape quotes in the Payments help text
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
The deploy's next build fails on react/no-unescaped-entities. Bump to
1.37.3; the handover now says to lint the whole frontend before tagging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:57:46 +02:00
joshua cd33ba281e Merge branch 'feat/payment-status'
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-23 15:28:31 +02:00
joshuaandClaude Opus 5.5 220dafb400 Bump to 1.37.2
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 15:28:31 +02:00
joshuaandClaude Opus 5.5 eb0f390215 Payment status checks for notifications that never arrive
- src/payments/status.js: open checkouts, checkCheckouts and the
  payment-status job (every 15 min, checkouts 10 min - 3 days old). Found
  payments are settled exactly as their webhook would be (settlePayment,
  extracted from the webhook handler: amount check, record, emails).
- PayFast: signed API client (alphabetical signature, testing=true in
  sandbox), findPayments from the transaction history CSV matched on
  M Payment ID, and testConnection (signed /ping). Verified against a
  real sandbox account.
- Yoco documents no checkout lookup, so its checkouts are "can't check".
- Payments -> Reconcile: "Waiting for payment" with Check status /
  Check all; PayFast card: Test connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:28 +02:00
joshua 55a7ee2970 Merge branch 'feat/hide-pay-online'
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-23 15:05:01 +02:00
joshuaandClaude Opus 5.5 4bc7dbc989 Bump to 1.37.1
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 15:05:01 +02:00
joshuaandClaude Opus 5.5 71b9da2458 Hide "Pay online" when no provider is on or the amount is under R15
A shared useOnlinePayments hook (GET /api/payments/providers) gates the
registration success page, My Events (Make payment, Make donation), the
donate page, Sponsor a spot, the register form's donation box and the
supervisor Payment Link tab; they explain why where it helps.

Roadmap: SnapScan and Zapper dropped; later versions move up by two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:04:58 +02:00
joshua 654f51b86d Merge branch 'feat/payfast'
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-23 14:26:09 +02:00
joshuaandClaude Opus 5.5 aeb5d6d678 Bump to 1.37.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 14:26:09 +02:00
joshuaandClaude Opus 5.5 f2b51dbd88 PayFast provider, Pay online page, amount checks on payments
- PayFast (custom integration): signed form (PHP-style encoding, checked
  against a real sandbox), ITN checks (signature over all posted fields,
  PayFast host, VALID from /eng/query/validate, amount vs checkout).
- ProviderCheckout records every checkout (amount, metadata, request);
  webhooks match payments to it and hold a different amount for review
  (ProviderTransaction.note).
- /pay/<id>: payers choose a provider when several are on, and the
  browser posts PayFast's form from there, so payment links work for
  every provider.
- Site settings → Payments: PayFast card (merchant ID/key, encrypted
  passphrase, sandbox/live) while PayFast is enabled.
- Payer-facing wording no longer names Yoco; legal pages name both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:25:49 +02:00
joshua 25e3e9d3a6 Merge branch 'feat/payment-providers'
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-23 12:17:56 +02:00
joshuaandClaude Opus 5.5 e7bed08a35 Bump to 1.36.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 12:17:56 +02:00
joshuaandClaude Opus 5.5 ede1ddbe00 Payment provider interface: Yoco behind it, Site settings → Payments
- src/payments: provider registry and PaymentProvider interface
  (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund,
  listTransactions as capabilities), Yoco as the first provider. All four
  direct Yoco checkout calls go through payments.createCheckout.
- Webhook split into the provider part (signature, parsing) and shared
  processing; same URL (/api/webhooks/yoco), same behaviour.
- YocoTransaction renamed in place to ProviderTransaction (+ provider,
  unique per provider + externalId); Payment.provider added and
  backfilled to "yoco". Payment.method stays the wallet type so cashups
  are unchanged.
- Site settings → Payments: Enabled toggle and order per provider; Yoco's
  settings shown while it's enabled. R15 minimum for all online payments.
- New endpoint names (/api/payments/checkout, /api/provider-transactions);
  the old /yoco-checkout and /yoco-transactions paths still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:17:29 +02:00
joshua 8933a1fe77 Merge branch 'feat/session-gaps'
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-23 11:38:43 +02:00
joshuaandClaude Opus 5.5 30e5db82df Bump to 1.35.1
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 11:38:43 +02:00
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- Waitlist per session: entries for pick-sessions tickets name a session;
  offers use that session's free places, never run past its start, and
  claim links tick it. Held offers count towards session capacity.
- At the door: the Select items pop-up has the session picker, with
  today's session preselected for new attendees.
- On pick-sessions events every ticket shows the session list straight
  away. "All sessions" tickets record the ticked sessions
  (RegistrationOption.sessionIds) at one price; scanning, attendance,
  certificates, reminders and capacity follow them.
- Fix: per-session stock limits weren't enforced (takenCount filtered
  WaitlistEntry by a sessionId column that didn't exist, and the error
  was swallowed), and the public event page lost its stock info.
- Fix: editing a door registration with picked sessions couldn't be saved.
- Fix: the offline-scanning service worker served /_next/static cache-first
  forever, so dev browsers that had opened the scanner ran stale code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00
joshua 7c3f4694e2 Merge branch 'feat/pick-sessions'
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-23 09:25:35 +02:00
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
joshuaandClaude Opus 5.5 15b73d5d9e Merge branch 'feat/unified-ticket-design'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:24 +02:00
joshuaandClaude Opus 5.5 c74a943709 Bump to 1.30.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:21 +02:00
joshuaandClaude Opus 5.5 28c8fe9715 Use the dashboard ticket design for all tickets
The emailed/WhatsApp ticket PDF and the staff and at-the-door print
views now match the user dashboard's printed tickets: rounded cards with
a light border and soft shadow. Printing goes through a shared helper in
lib/ticketPrint.ts, which also escapes names in the print HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:07 +02:00
joshuaandClaude Opus 5.5 2e7bf263ee Merge branch 'feat/registration-donation'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:17 +02:00
joshuaandClaude Opus 5.5 377630473d Bump to 1.30.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:11 +02:00
joshuaandClaude Opus 5.5 286cf09dd8 Add optional donation to registration; fix soldOutLines server crash
The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

Move soldOutLines() out of the "use client" JoinWaitlistButton module
into lib/waitlist.ts so the server-rendered event page can call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:27:47 +02:00
joshuaandClaude Opus 5.5 27c870d46d Bump to 1.29.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:17 +02:00
joshuaandClaude Opus 5.5 a0c5a6a98c Merge branch 'feat/coupons-redesign'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 e7969429b6 Redesign the Coupon codes page to match Bundle deals
Cards instead of the table, phone-friendly sheets for the form and the uses list,
inactive codes hidden by default, and the events picker shows upcoming events only
(with search and a Show past events option).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 4a492a8b16 Bump to 1.29.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 294fff0b41 Merge branch 'feat/bundle-types'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 f33d04a57c Add bundle types: pick any, buy X get Y free, group discount
Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for
a set price), free (cheapest freeCount in each set free) and group (setSize or more
qualifying tickets at a per-ticket price or % off, applied once). The supervisor page
has a type picker, buyers see a one-line description of each deal, and the Complete
bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 6b186b3481 Bump to 1.28.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:22 +02:00
joshuaandClaude Opus 5.5 7ba40163db Fix the bundle edit form's ticket rows
The quantity input had both w-full and w-16; w-full won, filling the row and pushing
the ticket picker off the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:21 +02:00
joshuaandClaude Opus 5.5 428b438251 Bump to 1.28.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:04 +02:00
joshuaandClaude Opus 5.5 6ce59af0ea Redesign the Bundle deals page
Upcoming events only by default (with a Show past events option), bundles as cards
instead of a table, and a mobile-friendly edit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:03 +02:00
joshuaandClaude Opus 5.5 53ed72d032 Add bundle deals to manual registration
Lists the event's deals and shows the Complete bundle / + cards, as at the door and
on the kiosk. Part of 1.28.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:32:17 +02:00
joshuaandClaude Opus 5.5 35d02c243b Bump to 1.28.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:49 +02:00
joshuaandClaude Opus 5.5 e2e6ec0e97 Merge branch 'feat/bundles-door-kiosk'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 92ab8065ef Add bundle deals to at the door and the kiosk
Both list the event's deals and show the Complete bundle / + cards from the register
form. At the door gets the standard Back to dashboard link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 084e3c6679 Bump to 1.28.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 de196e34f2 Merge branch 'feat/bundle-deals'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 763b66ff56 Add bundle deals
Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 8f92a8c3e9 Bump to 1.27.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:48 +02:00
joshua 6fc6c35214 Merge branch 'chore/demo-history' 2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 be1d6f2e31 Add ten completed events to the demo data
set-demo now also stages ten past events with door sales, staff cash
counts, check-ins, refunds, donations and feedback. Their cashups run
through the real cashup code after the insert and cover every path:
quick close, full settlements (card fees, deposit fee, shortfall, cash
on hand, an unaccounted amount), close-reopen-close, and pending EFT
money received later or still outstanding. One recent event is left
open with the cash counted for a live demo. Funnel sessions older than
90 days are rolled up into daily totals, as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 dc02f6b9ff Bump to 1.27.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:29 +02:00
joshua 103c328a6a Merge branch 'feat/cashup-settlement' 2026-10-21 13:29:28 +02:00
joshuaandClaude Opus 5.5 7446388bfa Take cash deposit fees out of the counted cash
A bank charge on a cash deposit comes out of cash that was counted and
banked, so cash on hand is now the count less what reached the bank and
any bank-charge write-off. Before, a deposit fee showed as money "more
than expected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:17 +02:00
joshuaandClaude Opus 5.5 8a4c144f6d Add cashup settlement and back links to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:19 +02:00
joshuaandClaude Opus 5.5 3a68b24a0f Settle cashups: where the money ended up
A full cashup now records, per payment method, how much of the expected
takings is in the bank, still on hand (cash) or pending (on the card
report or with a POP), and writes off what was lost to card fees, bank
charges or a shortfall. Whatever is left is shown as unaccounted; closing
with an unaccounted amount is allowed after a warning.

Pending money can be marked received or written off after the event is
closed, without reopening it. Reopening starts the next cashup from the
last one. Write-offs come off net profit and show as "Bank & card
charges" in the cashup, finance and profit reports.

The reports page also moves to the shared back link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 9be78c3c34 Use one back link style across the dashboards
Every dashboard page now uses the shared BackLink, the small
"← Back to dashboard" link above the page title that the Waitlist page
already had, instead of a mix of grey Back buttons in different places.
Pages that had no way back (site settings, profile, donate, manual
registration) get one too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 d268236686 Bump to 1.26.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:51 +02:00
joshua 95a8bd0796 Merge branch 'chore/demo-data' 2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 101632e758 Add set-demo script for staging demo data
npm run set-demo wipes the database, keeping the site settings and the
admin account, and stages six upcoming events with registrations,
payments, tickets, a waitlist, camp cabins, coupons, costs and funnel
stats for showing the platform to other churches. Documented in
backend/DEMO_DATA.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 7cf5f79d01 Merge branch 'chore/frontend-lint'
Includes chore/deps-security-prisma7 (Prisma 7 upgrade and npm audit fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 00464c086d Bump to 1.26.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 4e07034bbd Add frontend lint cleanup to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:11:49 +02:00
joshuaandClaude Opus 5.5 2d6c840614 Type the reports page and turn lint back on in builds
ReportsV2 gets typed API responses (funnel, feedback summary, coupon
redemptions, cashup audit, consent) and derived rows. Master-order
quantities move into a per-row map, so an option name can no longer
overwrite a fixed column. Consent row types move to src/types. Removes
unused export helpers and state.

With the frontend now at zero lint errors and warnings, next build runs
ESLint again (eslint.ignoreDuringBuilds is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 235da5bf61 Type the at-the-door page and share pricing helpers
Each door panel and modal gets typed props; unused setError/setInfo/
onCreated props are dropped. The current early-bird price helpers move
to src/lib/pricing.ts (shared with manual registration), dead fallbacks
for a legacy registration 'options' field are removed, and the refund
history now shows a refund's reason (stored in its status field) instead
of a 'reason' field that never exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 f6581eb441 Type payments and user dashboard pages
Moves the duplicated registration-option pricing helper into
src/lib/pricing.ts and drops an unused users list from payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 0a8788afff Type supervisor forms, manual registration and events pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b560d0d340 Type registrations, email and WhatsApp messaging pages
Adds typed payloads and results for the email/WhatsApp attendee and
broadcast endpoints. The WhatsApp page's messageDirty flag becomes a ref
(it only gated the template effect), and a no-op branch in the email
template effect is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b2439a781e Type more frontend pages and fix their hook dependencies
Cashup, users, profile, consent, allocations, event options, forms,
event tickets, ticket scanning and manual registration now use real
types. Data loaders are wrapped in useCallback and listed as effect
dependencies. Also adds shared Attendee, Section and form response
types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 545f3d913e Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment,
Ticket, paginated results and dashboard stats, modelled on the Prisma
schema. apiFetch now defaults to unknown instead of any. Dashboard
loaders are wrapped in useCallback so their effects can list them as
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 619b5b9a93 Type caught errors as unknown in the frontend
Replace `catch (e: any)` with `catch (e)` and read the message through a
new errorMessage() helper. ApiError.data gets a real type, and
useDismissingState's setter keeps one identity across renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 958a5a5950 Fix frontend lint: links, unused code, quotes, img elements
- Internal links use next/link instead of <a> (no full page reload).
- Remove dead code: RegisterForm's unreachable Yoco checkout branch, the
  unused AttendeeForms on the registration success page, unused Geist
  fonts, and assorted unused variables and catch bindings.
- Escape quotes in JSX text; replace @ts-ignore in apiFetch with a cast.
- Keep the intentional raw <img> elements (data URLs, runtime API host)
  with a documented lint exception; the event editor uses ApiImage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 f2be3a8b45 Upgrade to Prisma 7 and clear npm audit advisories
- Prisma 5.22 -> 7.10 using the @prisma/adapter-pg driver adapter; the
  datasource URL moves to backend/prisma.config.js. All clients are built
  in src/config/db.js, and index.js reuses the shared client.
- Backend: patch follow-redirects, drop nodemon for `node --watch`, and use
  js-yaml 4 for the Jest coverage loader. npm audit reports 0.
- Frontend: override postcss-selector-parser to 7.1.7 (Tailwind 3 output
  is unchanged). braces has no patched release and stays, build/lint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:58:11 +02:00
joshua ce22f44f7c Merge branch 'fix/mobile-cookie-banner' 2026-10-20 12:47:41 +02:00
joshuaandClaude Opus 5.5 30b567f091 Bump to 1.25.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:36 +02:00
joshuaandClaude Opus 5.5 0b22eca0fb Lift cookie banner and help button above mobile bottom nav
The cookie consent banner was fixed at bottom-0 under the z-50 BottomNav, so
on mobile it was hidden. It now sits above the nav (h-16 + safe-area) and
publishes its height as --cookie-banner-h, which HelpFab adds to its offset
so the banner no longer covers the help button either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:13 +02:00
joshua 5529811119 Merge branch 'feat/yoco-settings' 2026-10-20 10:28:57 +02:00
joshuaandClaude Opus 5.5 1f0b2b1425 Bump to 1.25.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:53 +02:00
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00
joshua 921b94a66a Merge branch 'feat/privacy-transfer-accept' 2026-10-19 18:46:43 +02:00
joshuaandClaude Opus 5.5 b3bfbe486c Bump to 1.24.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:25 +02:00
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
joshuaandClaude Opus 5.5 ccb4db919a Bump to 1.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:25:08 +02:00
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00
joshua fb8ce5f86d Merge branch 'feat/funnel-stats' 2026-10-19 18:14:29 +02:00
joshuaandClaude Opus 5.5 4cd89b1a60 Bump to 1.22.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:24 +02:00
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00
joshua 238fcd6fd7 Merge branch 'feat/feedback-forms' 2026-10-19 15:28:48 +02:00
joshuaandClaude Opus 5.5 78b64329cc Bump to 1.21.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:28:42 +02:00
joshuaandClaude Opus 5.5 afcc14cbb3 Add post-event feedback forms
A Feedback step in the event wizard builds a survey that is sent at a
chosen time to everyone who checked in (or everyone registered), with
personal links and one reminder after 3 days. Answers can be anonymous.
Adds rating (1-5 stars) and multiple-choice field types, also usable on
attendee forms, and a Feedback summary report with response rate,
average ratings, choice breakdowns, comments and event comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:28:29 +02:00
joshua 1b92fae76c Merge branch 'feat/sponsor-a-spot' 2026-10-19 15:04:40 +02:00
joshuaandClaude Opus 5.5 4c76c77005 Bump to 1.20.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:37 +02:00
joshuaandClaude Opus 5.5 93cff6099e Add sponsor a spot
Events can allow sponsorships: a "Sponsor a spot" button lets anyone
(guests included) pay through Yoco for whole spots or a custom amount,
anonymously or with a name and message. Payments land as event
donations in a sponsorship pool that supervisors assign with the
existing donation flow. Adds optional public progress towards a goal,
a sponsor receipt and Automations thank-you, pool columns in the
Donations report, and sponsorship labels/filter in Payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:29 +02:00
joshua 7bcaec6284 Merge branch 'feat/room-allocation' 2026-10-19 14:45:09 +02:00
joshuaandClaude Opus 5.5 2f4fd7c040 Bump to 1.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:45:06 +02:00
joshuaandClaude Opus 5.5 1516e104df Add rooms & groups allocation
Supervisors can split an event's named attendees into allocation sets
(rooms, small groups, buses) with capacity, gender and leader per group,
using a drag-and-drop board or auto-allocate (keep bookings together or
split them). Adds printable group and master lists (medical view
audited), the group on tickets and in My Events, messaging by group,
a "My groups" page for leaders, and an optional per-event gender
question for named attendees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:44:58 +02:00
joshua e9588aeba8 Merge branch 'feat/ticket-transfer' 2026-10-19 14:23:34 +02:00
joshuaandClaude Opus 5.5 83df90b531 Bump to 1.18.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:31 +02:00
joshuaandClaude Opus 5.5 87ffe3b84b Add ticket transfer
Payers and holders can hand a ticket to someone else before the event.
The new holder gets a new QR code (and an inactive account with an
activation link if they're new); the old code stops working and scans
of it, online or in offline sync, say it was transferred. One place can
be split off a shared group ticket. Transfers can be turned off per
event (supervisors can always transfer from At the door), youth events
need the new holder's own consent, and every transfer is audited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:24 +02:00
joshua 9a4bf51ad4 Merge branch 'feat/offline-scanning' 2026-10-19 14:06:40 +02:00
joshuaandClaude Opus 5.5 d84663f901 Bump to 1.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:34 +02:00
joshuaandClaude Opus 5.5 be621c49a2 Add offline ticket scanning
Staff can lock a section (or the whole event) to their device, download
its tickets and keep scanning with no connection. Scans are queued in
IndexedDB and synced idempotently when back online; conflicts go into a
sync report for supervisors, who can also force-release a lost device.
The server now validates scan sections and refuses scans in a scope
locked to another device. Adds a web app manifest and a service worker
for the scanner page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:22 +02:00
joshua edf7c3052b Merge branch 'feat/waitlist' 2026-10-19 13:43:20 +02:00
joshuaandClaude Opus 5.5 f66968768f Bump to 1.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:43:16 +02:00
joshuaandClaude Opus 5.5 2166f94247 Add waitlist for sold-out tickets
Events can take a waitlist for sold-out lines. When places free up
(cancellation, smaller booking, higher stock limit) the next person is
offered them with a claim link and the places are held for a set time;
unclaimed offers expire and move on, with optional partial offers. Stock
is now counted in one place (utils/stock.js), including held places,
and manual registrations and booking edits are now stock-checked. Adds a
supervisor waitlist page, My Events waitlists, and the daily summary count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:43:10 +02:00
joshua bae9675485 Merge branch 'feat/parental-consent' 2026-10-19 13:25:02 +02:00
joshuaandClaude Opus 5.5 13fd3a4cbb Bump to 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:57 +02:00
joshuaandClaude Opus 5.5 809b6a11d7 Add parental consent for youth events
Youth events need a name and date of birth for every attendee, and a
parent/guardian's details for under-18s. Parents get an email and
WhatsApp link to a consent page that records the signed name, time, IP,
device and exact consent text; medical and emergency details are stored
encrypted. Tickets are issued but check-in is blocked until consent is
given (supervisor override audited). A daily job sends reminders and
purges medical details after the retention period. Adds a supervisor
consent tracker, consent status and medical list reports, and clears
consent data when an account is anonymised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:44 +02:00
joshua f1e0b62c73 Merge branch 'feat/group-tickets' 2026-10-19 12:12:52 +02:00
joshuaandClaude Opus 5.5 72ecf500c8 Bump to 1.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:44 +02:00
joshuaandClaude Opus 5.5 df0bf09a0e Add group tickets with named attendees
Events can take a name (and optional email/phone) for each main-ticket
place. Each named person gets their own quantity-1 ticket and attendee
form; unnamed places share one ticket. Tickets go to the payer or to each
person, attendees matching one existing account are linked to it, and
the payer can edit names and send form links from My Events. Ticket
generation is now planned per line (utils/attendees.js), free
registrations use the same generator, and guest registration matches
accounts by phone too, refusing conflicting email/phone pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:33 +02:00
joshua 4b413788ac Merge branch 'feat/coupon-codes' 2026-10-19 11:43:36 +02:00
joshuaandClaude Opus 5.5 95dc3998ea Bump to 1.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:26 +02:00
joshuaandClaude Opus 5.5 709ea38c10 Add coupon codes and staff pricing
Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:10 +02:00
joshua 7ce125e36c Merge branch 'feat/required-2fa-staff-roles' 2026-10-19 11:00:51 +02:00
joshuaandClaude Opus 5.5 96bfeff205 Bump to 1.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:47 +02:00
joshuaandClaude Opus 5.5 c3012cb3f6 Require two-factor authentication for admins and supervisors
Admins and supervisors without 2FA are sent a code at their next login,
and verifying it turns 2FA on. They can change the channel but not turn
it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED,
and promoting a user ends their sessions so they enrol at next login.
The requirement is skipped when no email or WhatsApp channel can deliver
a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:12 +02:00
joshuaandClaude Opus 5.5 0306c4dcb1 Add FEATURES.md and refresh info.md
FEATURES.md is a repo-root overview of everything the platform does.
info.md, the overview for prospective churches, is brought up to date
with features added since it was written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:06:06 +02:00
joshuaandClaude Opus 5.5 fc4e65b80a Bump to 1.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:49:34 +02:00
joshuaandClaude Opus 5.5 e52bf67015 Add optional two-factor authentication
Users can turn on 2FA from Profile & Security and receive a 6-digit
login code by email or WhatsApp, with a "trust this device for 30 days"
option. Admins can reset it for a locked-out user, and the self-service
kiosk handles the code step at setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:49:34 +02:00
joshuaandClaude Opus 5.5 e909f43fea Bump to 1.10.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:37 +02:00
joshuaandClaude Opus 5.5 889995e450 Fix frontend build hanging when the backend is unreachable
Server-side fetches now fail fast when no API base URL is configured,
and the home and events pages build with an empty list if /api/events
can't be reached during next build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:30 +02:00
joshuaandClaude Opus 5.5 73fdce1879 Update dependencies to clear npm audit advisories
Run npm audit fix in root, backend and frontend, bump nodemailer to 10,
override shell-quote in the root package, and allow-list the dependency
install scripts npm was blocking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:22 +02:00
368 changed files with 46201 additions and 5834 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'"
+9 -1
View File
@@ -31,6 +31,9 @@ yarn-error.log*
.DS_Store
Thumbs.db
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
/ecosystem.config.js
# misc scratch / generated files
temp/
backend/public/uploads/
@@ -39,4 +42,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/
+644
View File
@@ -7,6 +7,650 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.40.0] - 2026-10-23
### Added
- **POPIA toolkit, part 3.** Three new tabs on Admin → Privacy (POPIA):
- **Readiness** (now the first tab): a checklist with a score — Information Officer and legal details set, privacy notice published, operators described (no generic names, a location for each), retention reviewed and the job running, no overdue requests, breaches reported to the Regulator, unsubscribe links working (`BACKEND_URL` on https), email set up, WhatsApp STOP replies arriving, two-factor login required, backups running. Each open item links to where you fix it.
- **Retention**: how long personal information is kept, with what each setting would affect right now. A nightly job (`retention`, 03:30; `RETENTION_JOBS_ENABLED=false` to switch off) applies it:
- **Inactive accounts** — no login, registration, payment or being named as an attendee for **5 years**: a warning by email (or WhatsApp) 30 days before, then the account is anonymised. Logging in keeps it. Never staff, supervisors or admins, and never while they have a registration for an upcoming event. **On by default.**
- **Attendee form answers** — deleted 12 months after the event.
- **Login history** — kept 24 months.
- **Payer names and emails in Yoco/PayFast notifications** — removed after 24 months (payments themselves are kept).
- **Audit log, access log and "view as" sessions** — kept 5 years.
- Medical details, visit statistics and backups keep their existing settings, shown alongside. "Run now" applies them straight away.
- **Breaches**: a register of security compromises (what happened, what information, who, consequences, what was done, advice to the people affected), with status and the dates the Information Regulator and the people affected were told. Each entry has a draft message for the people affected and a summary for the Regulator's form, ready to copy.
- **Children's and medical information** is flagged in the access log (medical lists, and anything about an event that asks for parental consent), with a filter.
- The privacy notice gets a `{retention}` placeholder — one line per retention period, following the settings. The built-in notice uses it; if you already published your own version, add a line with `{retention}` to your retention section.
## [1.39.0] - 2026-10-23
### Added
- **POPIA toolkit, part 2: the admin side.** A new **Admin → Privacy (POPIA)** page with four tabs:
- **Privacy notice editor.** The privacy notice is now editable. It starts from the built-in text (updated for 1.38.0's privacy page, news opt-in, unsubscribe and STOP), one box per section; add, remove and reorder sections, save drafts, preview, and **publish numbered versions**. Placeholders such as `{org}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}` and `{operators}` are filled in when the notice is shown, so it follows Site settings and the operator register. Earlier versions stay readable on the privacy page (`/legal/privacy?version=N`).
- **Re-accepting the notice.** When publishing, tick "Significant change" (and say what changed) to ask everyone to accept the new version: they're asked once, after their next login, with a link to delete their account instead. Sign-up records which version was accepted. Acceptances are counted per version.
- **Operators** — the register of outside services that handle personal information (Yoco, PayFast, WAWP, WhatsApp, email and hosting providers, Google Maps, Sentry), with what each receives and where. Those in use are listed in the notice.
- **Consent log** — every news opt-in/opt-out (and how), every privacy notice acceptance, and every parental consent answer, searchable by person.
- **Access log** — who downloaded, emailed or opened personal information in bulk: report PDFs/Excel files (and reports emailed or WhatsApped), attendance registers, certificate downloads, room/group lists, attendee form answers, the parental consent and medical lists, database backups, and every "view as" session. With how many records each time.
- The contact page shows the Information Officer (when set in Site settings → Legal), with a link to the privacy notice.
- "Download my data" includes the privacy notice versions you accepted.
### Changed
- Deleting someone's personal data also removes the IP addresses kept with their consent and notice records (the records stay as proof).
## [1.38.0] - 2026-10-23
### Added
- **POPIA toolkit, part 1: privacy for the people in the system.**
- **Privacy page** (My account → Privacy): choose News & announcements by email and/or WhatsApp, see the history of those choices, **download my data** (a readable PDF, or the complete copy as JSON: account, registrations and the people registered, form answers, parental consent, payments, tickets and check-ins, certificates, feedback, communication choices), **ask for a correction**, and **delete my account**.
- **Two kinds of bulk message.** When emailing or WhatsApping attendees, or sending a broadcast, choose **Event update** (about an event the recipients registered for — goes to everyone selected) or **News & announcements** (only to people who opted in, with an unsubscribe link). Attendee messages default to event updates, broadcasts to news, and the next-event promo automation is always news. Previews say how many people were skipped.
- **Unsubscribe**: every news email ends with an unsubscribe link and carries one-click unsubscribe headers (Gmail, Apple Mail); every event update says why it was sent and links to the preferences. News WhatsApps end with "Reply STOP" and a link. Unsubscribed addresses without an account are remembered too.
- **WhatsApp STOP**: replying STOP (or "unsubscribe") to the church's number stops WhatsApp news and sends a confirmation. Needs WAWP's webhook pointed at the URL shown in Site settings → WhatsApp (it carries a secret, since WAWP doesn't sign webhooks).
- **News opt-in**: an unticked "news and announcements" box on sign-up. Existing accounts stay opted in (logged as "existing account") and are asked once, after their next login, to confirm or opt out. Every change is logged with when and how.
- **Data requests** (Admin → Data requests): correction and deletion requests with a 30-day due date, emailed to the Information Officer and admins, with a daily reminder for ones due within a week or overdue (`privacy` job, 08:00; `PRIVACY_JOBS_ENABLED=false` to switch off). Completing or rejecting a request emails the person and is in the audit log.
### Changed
- **Deleting an account is now a request.** "Delete my account" (and "Also delete my personal data" when closing an account) closes the account straight away; the personal information is deleted when an admin completes the request — after any registration for an upcoming event has been cancelled or the event is over.
- **Deleting personal data now goes much further.** Before, it only cleared the account's name, email and phone. Now it also removes the names and details of the people they registered (and their own details on other people's bookings), form answers, parental-consent details, sponsor names, feedback authorship, pending transfer requests, certificate names, login history, and payer names/emails kept from Yoco and PayFast notifications. Payments, registrations, tickets and check-ins are kept, as "Deleted User", for the books and reports. Admin → Users → Anonymise uses the same deletion, and is refused while the person has a registration for an upcoming event.
- "Download my data" is blocked while an admin is viewing as someone else.
## [1.37.4] - 2026-10-23
### Fixed
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
## [1.37.3] - 2026-10-23
### Fixed
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
## [1.37.2] - 2026-10-23
### Added
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
## [1.37.1] - 2026-10-23
### Changed
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
## [1.37.0] - 2026-10-23
### Added
- **PayFast.** A second online payment provider (card, instant EFT and more). Switch it on in Site settings → Payments, then enter the merchant ID, merchant key and salt passphrase from your PayFast (or Sandbox) dashboard and choose Sandbox or Live. Payments are confirmed by PayFast's notification (ITN) at `/api/webhooks/payfast`, which is checked the way PayFast requires: the signature, that it came from PayFast, that PayFast confirms it, and that the amount matches the checkout. PayFast starts switched off.
- **Choosing how to pay.** When more than one provider is on, payers choose on a new "Pay online" page (`/pay/…`). Payment links in emails, WhatsApp and invoices go there too, so they work for every provider.
- Payer-facing wording no longer says Yoco: "Pay online" after registering, a neutral redirect message, and the Terms and Privacy pages name both providers (Yoco or PayFast), including that PayFast may receive your name and email to fill in its payment page.
- A payment of a different amount from the checkout (e.g. changed along the way) isn't recorded automatically: it waits in "Unreconciled Online Payments" with a note explaining why.
## [1.36.0] - 2026-10-23
### Added
- **Site settings → Payments** (replaces the Yoco tab): turn each online payment provider on or off and set their order. The first one enabled and set up is used by default. Yoco's keys and webhook settings are on the same tab. Yoco is the only provider for now; PayFast, SnapScan and Zapper follow in the next releases. If no provider is on and set up, online payments are off and people pay at the door.
### Changed
- Online payments go through one payment provider layer (`backend/src/payments`). Yoco works exactly as before: the same checkout, the same webhook URL (`/api/webhooks/yoco`) and the same reconciliation.
- **Online payments must be at least R15**, for every kind of online payment (balance, part-payment, donation, sponsorship). A smaller balance is paid at the door.
- Payments record which provider took them (`provider`, e.g. "yoco"); existing online payments are marked as Yoco. The payment method (card, Apple Pay…) is unchanged, so cashups and reports add up as before. Provider fees are still never deducted or added — they're handled at cashup.
- Yoco transactions are now "provider transactions" (`/api/provider-transactions`, with `/api/yoco-transactions` still working). The Payments page's reconcile list is called "Unreconciled Online Payments" and shows the provider. The database table is renamed in place, so stored transactions are kept.
- The checkout endpoint is now `POST /api/payments/checkout` (with an optional `provider`); `/api/payments/yoco-checkout` still works.
## [1.35.1] - 2026-10-23
### Added
- **Waitlist per session.** On pick-sessions events, a full session shows "Join waitlist" in the session list, and the waitlist works per session: when a place in that session frees up, the next person waiting for it gets the offer. Offers never run past the session's start, and nobody is offered a session that has started or was cancelled. The claim link ticks the session for them. My Events, the claim page and the supervisor Waitlist page show which session an entry is for.
- **"All sessions" tickets on pick-sessions events let people choose their sessions.** When an event's "How people book" is "Pick sessions", every ticket now shows the session list. A ticket set to "All sessions" is still one ticket at one price, whatever is ticked. The ticket then only scans on the ticked sessions (online and offline), and the attendance register, certificates, session reminders and session capacity follow them. Tickets and the ticket email list the ticked dates. A ticket set to "Pick sessions" is still charged per session. Events booked as "The whole course" show no session list. Bookings made before this cover every session, as before.
- **Session picker at the door.** The At the door "Select items" pop-up now has the session picker for per-session tickets, so walk-ins can be booked straight in. For a new attendee, a place in today's session is selected by default when there's room.
### Changed
- Per-session tickets show their list of sessions straight away (on the registration page, Manual registration, At the door and the self-service kiosk). Before, the list only appeared after pressing "+", so it wasn't obvious you could choose sessions. Ticking a session sets the quantity to 1.
### Fixed
- Per-session stock limits on pick-sessions tickets weren't being enforced, and the public event page lost all stock information on events with such a ticket.
- The offline-scanning service worker served the app's script files from its cache first, for good. On a development server, where those files keep their names when they change, a browser that had opened the scanner kept running old code on every page, even after a hard reload. It now fetches them from the network and uses the cache only when offline. Its cache is renamed, so old copies are cleared.
- At the door, editing a registration that has per-session tickets could no longer be saved ("below the quantity already issued").
- Whole-course tickets: the waitlist now also counts room in sessions that have a capacity, so it no longer says "places available" when a session is full. Places held for waitlist offers now count towards session capacity.
## [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
- All tickets now share the design of the tickets printed from the user dashboard: rounded white cards with a light border and soft shadow, the date beside the event name, and the QR code in the middle. This covers the PDF sent by email and WhatsApp (now 6 per page, so the QR codes are bigger) and printing from Event Tickets (staff) and At the door (supervisor). Printed names are now escaped, so a name with `<` or `&` prints correctly.
## [1.30.0] - 2026-10-22
### Added
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
### Fixed
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
## [1.29.1] - 2026-10-21
### Changed
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
## [1.29.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- Bundle deals now come in four types, chosen when you create one:
- **Exact set**: these tickets in these numbers for one price, e.g. 2 adults + 3 kids for R220. This is how bundles worked before.
- **Pick any**: any mix of the qualifying tickets for a set price, e.g. any 4 of Adult or Child for R250, so 2 + 2 and 4 + 0 both count.
- **Buy X get Y free**: in every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free.
- **Group discount**: from a group size up, every qualifying ticket costs less (a price per ticket or a percentage off), e.g. 10+ tickets: R70 each. It applies once, to the whole group, and never makes a ticket dearer.
- The deals list and the "Complete bundle" cards on the register form, at the door, manual registration and the kiosk describe each type in one line, e.g. "Any 4 of Adult, Child for R250". For a group discount the button reads "Complete group" and adds the tickets still needed.
- `npm run set-demo`: Family Fun Day also has a pick-any deal, "Lunch for four" (any 4 lunch packs for R220).
## [1.28.3] - 2026-10-21
### Fixed
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
## [1.28.2] - 2026-10-21
### Changed
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
## [1.28.1] - 2026-10-21
### Added
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
### Fixed
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
- At the door's "less than already paid" warning shows the total after bundle deals.
## [1.28.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- **Bundle deals**: sell a set of tickets together for one price, e.g. 5 Adult + 3 Child for R500 instead of R550. Supervisors set them up per event under Dashboard → **Bundle deals**: the tickets and quantities (one variant or any variant), the price, optional dates, and on/off.
- Bundles apply automatically, with no code, as many times as a registration's tickets allow; leftover tickets are charged as usual. A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included. When several compete, the one that saves the most is used first. This works for online registration, manual registration, at the door and on the kiosk.
- The event page and the register form list the event's deals, and every basket shows the saving live. On the register form, once a ticket from a bundle is picked, the bundle appears with what's missing and a **Complete bundle** button that tops up the ticket numbers. Once it's complete, **+** adds another full set. Invoices, receipts, emails, WhatsApp messages and the Yoco payment page show it as its own line, e.g. "Bundle: Family deal ×2".
- The Discounts report lists bundle deals next to coupon codes. Each bundle shows how often it's been used and the total saving given.
- `npm run set-demo`: Family Fun Day sells a **Family deal** bundle (2 adults + 3 kids for R220) in place of the old Family pass ticket.
### Changed
- Coupon codes now work out after bundle deals, on what's left. Registration.discountTotal now holds bundle and coupon discounts together, with the bundle part in the new `bundleDiscount`.
- Removing a code from a registration keeps its bundle deal. When an early-bird tier expires before payment and prices change, bundle deals are worked out again and coupon amounts are kept.
- Registrations keep a bundle they already have, even after it's turned off or its dates pass. Deleting a bundle that has been used turns it off instead.
## [1.27.1] - 2026-10-21
### Added
- `npm run set-demo` also stages ten completed events from the past year, with door sales recorded by staff, staff cash counts, check-ins, refunds, donations and post-event feedback. Their cashups go through every path: a quick close; full closes with card fees, a cash deposit fee, a written-off shortfall, cash kept on hand and an unaccounted amount; a close, reopen and re-close; pending EFT money that was later received and some still outstanding; and one event left open with the cash counted, ready for a live cashup. Funnel stats older than 90 days are stored as daily totals, the way the app rolls them up. See `backend/DEMO_DATA.md`.
## [1.27.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- Cashups now record where each payment method's money ended up. For cash, card, EFT and other, the expected takings are split into what's **in the bank**, **cash on hand**, and **pending** (on the card machine or Yoco report, or with a proof of payment), with a reference for the pending amount. Card fees, bank charges, cash shortfalls and other losses are **written off** with a reason. Each method shows what's still **unaccounted**. Closing with an unaccounted amount is allowed after a warning, and the amount comes off profit, as variance did before.
- When staff have counted the cash, cash on hand is worked out for you: the count, less what reached the bank and any deposit fee written off as a bank charge.
- Pending money on a closed event can be marked **received** or **written off** later from the cashup screen, without reopening the event. Each one is logged with who did it and when.
- Reopening an event starts the next cashup from the last one, with any pending money settled since folded in.
- Write-offs show as **Bank & card charges** on the cashup screen and in the Cashup, Finance and Profit reports. The Cashup report has new In bank, On hand, Pending, Written off and Unaccounted columns.
### Changed
- Net profit now subtracts write-offs as their own line instead of showing card fees and bank charges as missing income. The net figure is the same.
- Every dashboard page uses the same back link: the small "← Back to dashboard" link above the page title, as on the Waitlist page. The grey Back buttons are gone. Site settings, profile, donate and manual registration, which had no way back, now have one too.
## [1.26.1] - 2026-10-21
### Added
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
## [1.26.0] - 2026-10-21
### Changed
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
### Fixed
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
### Security
- Backend `npm audit` now reports 0 vulnerabilities:
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
- Removed `nodemon`, the only backend package that pulled in `braces`.
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
## [1.25.1] - 2026-10-20
### Fixed
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
## [1.25.0] - 2026-10-20
### Added
- A **Yoco** tab in Site Settings for online payments:
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
### Changed
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
- Selecting several events compares them.
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
- The privacy policy's cookies section now describes these statistics.
- New `/api/analytics` endpoints.
- Requires the `add_funnel_analytics` database migration.
## [1.21.0] - 2026-10-19
### Added
- Post-event feedback, set up in a new Feedback step in the event wizard.
- Questions use the form builder.
- You pick when it's sent (the default is the morning after the event) and who gets it: everyone who checked in, or everyone registered.
- Each person gets a personal link by email (and WhatsApp where they use it).
- Anyone who hasn't answered gets one reminder 3 days later.
- Answers can be anonymous or named.
- "Send now" is available, and the questions lock once people start answering.
- New form field types: **Rating (1–5 stars)** and **Multiple choice**. They're used for feedback, and can also go on attendee forms (registration page, My Events, the kiosk, manual registration and Supervisor → Forms).
- New report, **Feedback summary**:
- response rate;
- average rating per question, with star distribution;
- multiple-choice and yes/no breakdowns as bar charts;
- a list of text answers (with names unless the form is anonymous).
- Select several events to compare their ratings (e.g. Camp 2025 vs Camp 2026). Exports to PDF and Excel like other reports.
- New `/api/feedback` endpoints and a job every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to turn it off).
- Requires the `add_feedback_forms` database migration.
## [1.20.0] - 2026-10-19
### Added
- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot).
- It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it.
- Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email.
- Sponsors can give anonymously, or add a name and a message for the organisers.
- An optional goal ("12 of 20 spots sponsored") and public progress bar.
- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool.
- Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly.
- Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown.
- Who receives help is never shown to sponsors.
- Sponsors get a "Thank you for sponsoring a spot" receipt.
- Automations has a new "Thank Your Sponsors" email for events with sponsorships.
- Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`).
- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining.
- New `/api/sponsorships` endpoints.
- Requires the `add_sponsorships` database migration.
## [1.19.0] - 2026-10-19
### Added
- Rooms & groups (Supervisor → Rooms & groups), for camps and lockdowns: put attendees into rooms, cabins, small groups, teams or buses.
- Each event can have several allocation sets (e.g. "Rooms" and "Buses"). Each set has groups with an optional capacity, an optional gender restriction and an optional leader (a staff member).
- A drag-and-drop board puts people not yet placed on the left and groups, with capacity counters, on the right. Full groups and groups for the other gender won't accept a person.
- Auto-allocate fills groups evenly with everyone not yet placed, respecting capacity and gender. It can keep people on the same booking together or spread them out.
- Printable lists per group (with leader) and a master list. On youth events they can include medical and emergency details from consent forms; viewing those is recorded in the audit log.
- "Show on tickets" prints the group on each person's ticket and shows it in My Events.
- Email and WhatsApp attendees can be sent to one group ("Bus 2 leaves at 07:30"). Messages go to whoever booked for the people in it.
- Group leaders see their people under Staff → My groups. Medical details are shown to supervisors only.
- New event setting "Ask each person's gender" (under Named attendees). It adds a gender choice for each named person on the registration page, at the door, on the kiosk, in manual registration and in My Events, and is needed for gender-split rooms.
- New `/api/allocations` endpoints.
- Requires the `add_room_allocation` database migration.
## [1.18.0] - 2026-10-19
### Added
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
- For a ticket covering several people, one place is split off to the new holder.
- The payer stays responsible for the registration.
- Transfer rules:
- Only before the event starts, and only for tickets that haven't been scanned.
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
- The previous holder's attendee form answers and consent medical details are removed.
- Every transfer is recorded in the audit log.
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
- Requires the `add_ticket_transfer` database migration.
## [1.17.0] - 2026-10-19
### Added
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
- With no connection, scans are checked against that list and queued on the device.
- The panel shows when the list was downloaded and how many scans are waiting.
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
- "Release section" ends offline mode and deletes the list from the device.
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
- New `/api/scan-locks` endpoints.
- Requires the `add_offline_scanning` database migration.
### Changed
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
- It now refuses (423) scans in a section that another device has locked for offline scanning.
## [1.16.0] - 2026-10-19
### Added
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
- The claim link opens the register page with the places pre-selected, and works without logging in.
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
- The daily summary email shows each event's waitlist count.
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
- Requires the `add_waitlist` database migration.
### Fixed
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
## [1.15.0] - 2026-10-19
### Added
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
- Medical and emergency details are stored encrypted.
- The registrant and the event's notification recipients are told the outcome.
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
- New reports: "Parental consent status" and "Medical & emergency list".
- My Events shows each attendee's consent status, with a "Resend consent" button.
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
- Requires the `add_parental_consent` database migration.
## [1.14.0] - 2026-10-19
### Added
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
- The payer stays the account owner for billing, invoices and payments.
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
- Requires the `add_named_attendees` database migration.
### Changed
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
### Security
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
## [1.13.0] - 2026-10-19
### Added
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
- Registration responses now include `discountTotal` and `subtotalComputed`.
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
### Fixed
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
## [1.12.0] - 2026-10-19
### Added
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
### Changed
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
## [1.11.0] - 2026-10-08
### Added
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
### Changed
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
## [1.10.6] - 2026-10-08
### Fixed
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
### Security
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
## [1.10.5] - 2026-10-01
### Fixed
+697
View File
@@ -0,0 +1,697 @@
# Hope Events — Feature Overview
A complete map of what the platform does, grouped by who uses it. For setup and
deployment see [README.md](./README.md); for the API see
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
defaults see [SETTINGS.md](./SETTINGS.md); for history see
[CHANGELOG.md](./CHANGELOG.md).
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
---
## Contents
1. [Roles](#roles)
2. [Public site](#public-site)
3. [Events](#events)
4. [Registration](#registration)
5. [Payments, donations & refunds](#payments-donations--refunds)
6. [Tickets & check-in](#tickets--check-in)
7. [Attendee forms](#attendee-forms)
8. [Messaging & notifications](#messaging--notifications)
9. [User dashboard (My Events)](#user-dashboard-my-events)
10. [Staff tools](#staff-tools)
11. [Supervisor tools](#supervisor-tools)
12. [Admin tools](#admin-tools)
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security)
17. [Privacy (POPIA)](#privacy-popia)
18. [Help system](#help-system)
19. [Background jobs](#background-jobs)
20. [Operations](#operations)
21. [Page map](#page-map)
---
## Roles
Four roles, each including everything the roles below it can do:
| Role | Can do |
|------|--------|
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
---
## Public site
- **Home page** — hero section with the organisation's name and branding, upcoming
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. 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
**Add to calendar** (`.ics`) button.
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
to the event. Only plausible aliases hit the database, so bot probes are
rejected cheaply.
- **Contact page** (`/contact`) — organisation email, phone, address and map.
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
- **Site banner** — a site-wide announcement bar set by supervisors.
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
lockdown event.
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
Open Graph/Twitter link-preview metadata.
- **Theming** — brand colours, logo and favicon come from Site Settings.
---
## Events
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. "All sessions" tickets on such an event also show
the sessions: people tick the ones they'll come to and pay the ticket's one price; the
ticket scans, reminds and counts towards capacity for those sessions only. The waitlist
works per session (a full session shows "Join waitlist"). Bookable from the registration page, Manual registration,
At the door (today's session is preselected) and the kiosk. 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.
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
with an optional price override and its own stock limit.
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
option or variant. Prices are locked in per purchase ("tranches"), so buying
more later doesn't re-price what was already bought.
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
certain ticket options, so a scanner at that point only accepts those tickets.
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
go-live date, require login vs. allow guest registration, or no registration
at all.
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
event page.
- **Notify recipients** — choose which staff get registration/payment alerts and
the daily summary for the event.
- **Sold-out handling** — an event is sold out when every stock-limited option
is exhausted. "X remaining" badges appear at a threshold that scales with
capacity.
---
## Registration
- **Online registration** (`/register/[eventId]`) — pick options, variants and
quantities; see early-bird prices and stock warnings; fill in the event form.
- **Guest registration** — when an event doesn't require login, an inactive
account is created and an activation link is sent by email or WhatsApp.
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
- **Edit options** — users can add or change items on their registration
(not for past or closed events).
- **Self-cancellation** — allowed only before any payment; admins can cancel
any time.
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
early-bird prices are re-evaluated, and the user is warned if the total
changed.
- **Manual registration** (supervisor) — register someone on their behalf,
creating an account if needed. Includes a "Record Payment" tab.
- **Duplicate-account protection** — if an email and phone belong to two
different accounts, the registration is blocked instead of merging them.
- **Group tickets** — on events with "Named attendees" turned on, someone booking
several places can name each person (with optional email/phone). Each named person
gets their own ticket and their own attendee form; unnamed places share one ticket.
Tickets go all to the payer, or each to the person (falling back to the payer).
Attendees matching an existing account see their ticket in My Events. Names can be
added or changed later from My Events, and attendees can be sent a link to fill in
their own form. Available on the registration page, manual registration, At the
door and the kiosk.
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
(on the event page, the register page and the event list). When places free up — a
cancellation, a smaller booking or a higher stock limit — the next person is
emailed/WhatsApped a claim link and the places are held for them (24 hours by
default) so nobody else can buy them. Unclaimed offers expire and move on; people
who asked for more than came free can be offered what's free or skipped. Claiming
works without logging in. People can leave from My Events. On pick-sessions events
people wait for a particular session; offers never run past the session's start.
- **Parental consent (youth events)** — on events that require it, every attendee
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
a WhatsApp with a consent link. They read the event's consent text, optionally give
medical info, an emergency contact and photo consent, type their name to sign, and
give or decline. Tickets are issued but can't be checked in until consent is given
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
Medical details are encrypted and deleted a set number of days after the event.
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
"Have a code?" on the registration page, in My Events (for unpaid registrations),
manual registration, At the door and the kiosk. Codes can be limited to events,
ticket options or variants, dates, total and per-person uses, a minimum quantity,
and specific people, roles or an email domain (restricted codes need a login).
Early-bird prices either stack or the better price wins. The discount is locked in
when applied and shows on invoices, receipts, emails and the Yoco checkout;
cancelling frees the use. Managed from Supervisor → Coupon codes.
- **Staff price** — a ticket option or variant can have a staff price that Staff and
above get automatically, without a code.
---
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Payments, where providers are switched on or off and put in order
(the first one is the default). Online payments are at least R15; payments are
recorded at the full amount and provider fees are dealt with at cashup.
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
modes. When several providers are on, payers choose on the "Pay online" page; a
payment of a different amount from the checkout is held for review instead of being
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
online.
- **Payment status checks** — if a provider's notification never arrives, Payments →
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
from the registration itself.
- Each record holds only the stage, the traffic source (utm_source, or the short-link
alias, which short links now pass as `?src=`) and the device type.
- The Registration funnel report shows counts and conversion per stage over a date
range, a daily trend, and breakdowns by source, device and ticket option.
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
events.
- Raw rows are rolled up into daily totals after 90 days.
- **Post-event feedback** — a Feedback step in the event wizard.
- Questions use the form builder, with new **star rating (1–5)** and **multiple
choice** types (also available on attendee forms).
- The form goes out at a chosen time to everyone who checked in, or to everyone
registered, each with a personal link (`/feedback/<token>`). One reminder goes
3 days later to anyone who hasn't answered.
- Answers can be anonymous or named. "Send now" is available, and the questions
lock once people have answered.
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
lets anyone, including guests, pay for one or more spots (at the main ticket price)
or any amount through Yoco. They can give anonymously or add a name and a message.
The money is a donation tagged to the event and goes into its sponsorship pool, which
supervisors assign to registrations with the usual donation flow.
- An optional public progress bar ("12 of 20 spots sponsored") with an optional goal.
- Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an
event's sponsors.
- The Donations breakdown report shows each pool: received, assigned and remaining.
- Who is helped is never shown.
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
Supervisors can **assign** a donation (fully or partly) to someone's
registration and **unassign** it later. Assignments are kept as separate
records, so the original donation never changes.
- **Online payment reconciliation** — provider (Yoco) transactions can be matched to a
registration, recorded as a donation (optionally choosing the donor), or
ignored.
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
automatically and re-sendable on demand by email or WhatsApp.
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
"Card" in reports and user views.
---
## Tickets & check-in
- **Automatic tickets** — QR-coded tickets are generated when a registration is
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
- **Quantity tickets** — one ticket can cover several people and be redeemed
partially (e.g. check in 2 of 3).
- **Named tickets** — group bookings can give each person their own ticket, shown
with their name on the PDF, the scanner and the staff ticket list.
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
for the selected event/section, then a confirmation screen shows holder, type
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
with no connection; scans are queued on the device and sync automatically when it's
back online (never redeeming more than is left). Only one device can hold a section,
and while it's locked nobody else can scan it, online or offline. Problems found
while syncing go into a report for a supervisor. Supervisors can force-release a
lost device. The scanner can be added to the home screen (PWA) and opens offline.
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
- **Resend** — send tickets to a different email/phone at the door.
---
## Attendee forms
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
---
## Messaging & notifications
Every user picks a **notification preference**: Email, WhatsApp, or Both.
**Automatic notifications**
| Trigger | Goes to |
|---------|---------|
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
| Refund processed | Registrant + admins |
| Tickets generated | Registrant (ticket PDF) |
| Ticket checked in | Ticket holder |
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
| Account registered, activation, login, password changed/reset, account closed | The account holder |
**Bulk messaging** (supervisor)
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
specific attendees, preview, send now or schedule for later.
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
outstanding balance).
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
one event.
- **Message type** — Event update or News & announcements; news only reaches people
who opted in (see [Privacy](#privacy-popia)).
- **Automations** — schedule a sequence of lifecycle emails for an event
(pre-event, final reminder, thank-you, promo for another event).
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
run in the background and are purged 24 h after sending.
**WhatsApp** is connected through the WAWP API: an admin links a number in
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
logout controls. Disconnected sessions are recovered automatically.
---
## User dashboard (My Events)
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
tags. Past and closed events are hidden by default.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
always transfer, from At the door → Tickets). On youth events the new holder needs
their own parental consent, and the previous holder's form answers are removed.
Every transfer is in the audit log.
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
date, method and payment/refund.
- **Donate** page.
- **Forms** — complete outstanding attendee forms.
- **Profile & Security** — edit details and notification preference, change
password, see recent account activity (logins, password changes, device),
sign out everywhere, deactivate or delete my data.
---
## Staff tools
- **Staff dashboard** — scan stats and quick actions.
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
with event and section filters.
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
and print an event's tickets.
---
## Supervisor tools
- **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 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
ticket printing/resending.
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
unlocks for an event, where visitors register themselves. Existing accounts
are matched by email/phone, and any change to their details needs
confirmation.
- **Manual registration** — register someone on their behalf and record their
payment.
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
Reconcile (Yoco transactions), and Payment Link.
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
discount codes, and see who used each one.
- **Rooms & groups** (`/dashboard/supervisor/allocations`):
- Per event, allocation sets such as "Rooms", "Small groups" or "Buses". Each set
has groups with an optional capacity, gender restriction and leader (staff).
- A drag-and-drop board, plus auto-allocate: fill evenly, respecting capacity and
gender, keeping bookings together or splitting them.
- Printable per-group and master lists. On youth events they can include medical
details, and viewing those is audited.
- The group can show on tickets and in My Events.
- Email and WhatsApp attendees can be filtered to one group.
- Leaders see their people under Staff → **My groups**.
- Works on named attendees. Events can ask each person's gender.
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
offline locks (with force release) and sync reports to review.
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
place now, move people up or down, or remove them.
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
attendee on youth events, resend (one or all pending), re-request after the
consent text changes, and a printable medical & emergency list (audited).
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
- **Forms** — view, filter, edit and print form responses.
- **Reports** — see [Reports](#reports).
- **Site banner** (`/set-banner`) — set the announcement bar.
---
## Admin tools
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
- **Manage users** — server-side search and filters (role, active), paging,
edit role and notification preference, revoke sessions, delete or
**anonymise** personal data.
- **Data requests** — POPIA correction and deletion requests (see
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
- **Site settings** — see below.
---
## Cashup & event costs
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
option), with a "paid from takings" method so cash on hand is correct.
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
compared with actual amounts, with variance shown.
- **Cash denomination counts** — entered per staff member, at any time. The
event's actual cash is the sum of these counts.
- **Payment accountability by staff member** — what each person recorded, by
method, with live cash variance.
- **Close / quick close / reopen** an event — closing locks it from further
edits and hides it from the kiosk and user actions. Drafts are saved as you
go.
- **Report tab** — a read-only cashup summary, shown automatically once the
event is closed.
- **Cashup audit trail** — every close/reopen with totals and who did it.
---
## Reports
A full-width workspace with shared filters (events, date range with presets,
include past/inactive/closed) and a searchable grid of reports, each opening in
a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
deep-link into reports with `?report=&range=`.
---
## Site settings & setup wizard
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
details, the first admin account and branding. No manual seeding needed.
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
applied without a restart. Secrets are encrypted with AES-256-GCM.
| Tab | Contains |
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Payments | Online payment providers: Enabled toggle and order; PayFast merchant ID/key, passphrase and sandbox/live; Yoco's secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Accounts & security
- Register, log in, forgot/reset password, activate account (for accounts
created on someone's behalf).
- JWT authentication (HS256 pinned) with **session revocation** (token
versioning) — users can sign out everywhere, and admins can do it for any user.
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
**Required for Admins and Supervisors**: their next login sends a code that turns
it on, and they can change the channel but not turn it off (skipped only when no
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
is the emergency switch). Optional for everyone else from Profile & Security.
Admins can reset it for a locked-out user, and the kiosk asks for the code at
setup.
- **Login lockout** after repeated failed attempts, plus login rate limiting
(`TRUST_PROXY` to read real IPs behind a proxy).
- **Security event log** — logins, password changes and resets, with device
and IP.
- **Account closure** — deactivate, or close and ask for the personal data to be
deleted (see [Privacy](#privacy-popia)).
- **Upload hardening** — server-generated filenames, and uploads served with a
sandbox CSP and `nosniff`.
---
## Privacy (POPIA)
Tools that help a church handle personal information the way POPIA expects. They
support compliance; wording and retention choices are for the church's Information
Officer.
**For everyone** — the **Privacy** page (`/dashboard/user/privacy`):
- **News & announcements** opt-in per channel (email, WhatsApp), with the history of
every choice: when, and how (sign-up, privacy page, unsubscribe link, STOP reply…).
Sign-up has an unticked opt-in box; accounts that never answered are asked once
after logging in.
- **Download my data** — readable PDF, or the complete copy as JSON.
- **Correct my data** — profile fields directly; anything else as a correction request.
- **Delete my account** — closes the account at once and files a deletion request.
**Bulk messages** are either an **Event update** (about an event the recipients
registered for; always sent) or **News & announcements** (only to people who opted in
on that channel and haven't unsubscribed). News emails carry an unsubscribe link and
one-click `List-Unsubscribe` headers; news WhatsApps end with "Reply STOP" and a link.
Event updates say why they were sent and link to the preferences. A STOP reply on
WhatsApp turns off WhatsApp news (through WAWP's `message` webhook, checked against a
secret in the webhook URL).
**Privacy notice** (`/legal/privacy`) — edited in **Admin → Privacy (POPIA)**
(`/dashboard/admin/privacy`): sections start from the built-in text; drafts, preview,
and published, numbered versions (earlier ones stay readable with `?version=N`).
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
operator register. A version marked as a significant change is accepted once by everyone
after their next login; sign-up records the version accepted. The contact page shows the
Information Officer.
**Registers and logs** (same admin page):
- **Operators** — the services that process personal information (payment, WhatsApp,
email, hosting, maps, error monitoring), what each receives and where; those in use
are listed in the notice.
- **Consent log** — news opt-ins/opt-outs, privacy notice acceptances and parental consent
answers, searchable.
- **Access log** — every bulk export or view of personal information (reports, attendance
registers, certificates, group lists, form answers, consent and medical lists, backups)
and every "view as" session, with the number of records. Children's and medical
information is flagged (medical lists, and anything about a youth event).
- **Retention** — how long each kind of personal information is kept, applied by a nightly
job: inactive accounts anonymised after 5 years (warning 30 days before; never staff
roles or people with an upcoming registration), form answers deleted 12 months after
the event, login history kept 24 months, payer details in payment notifications removed
after 24 months, audit/access logs kept 5 years. The notice's `{retention}` placeholder
lists them.
- **Breaches** — the register of security compromises, with when the Information Regulator
and the people affected were told, and drafts of both notifications.
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
deletion requests with a 30-day due date, emailed to the Information Officer and
admins, reminded daily when due within a week. Completing a deletion anonymises the
person: name, contact details, the people they registered, form answers, consent
details, sponsor names, login history and payer details in provider notifications are
removed; payments, registrations, tickets and check-ins stay as "Deleted User". It
waits while they have a registration for an upcoming event. Everything is in the audit
log.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
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 (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`) |
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_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`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_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 |
---
## Operations
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
one above 500 MB of memory.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- **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).
---
## Page map
| Path | Who | What |
|------|-----|------|
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
| `/pay/[id]` | Public | Pay online: choose a provider, or go on to PayFast |
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Return pages after paying online |
| `/forms` | Public / User | Attendee forms |
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
| `/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 |
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/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`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+31 -3
View File
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
- DATABASE_URL
- JWT_SECRET
- PORT (optional; defaults to 3000 if not set)
- YOCO_SECRET_KEY (for payments)
- YOCO_WEBHOOK_SECRET (to verify webhooks)
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
- FRONTEND_URL or APP_BASE_URL (used in email links)
@@ -69,6 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Payments: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
@@ -82,8 +81,36 @@ 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, on the server in the repo folder:
- `bash scripts/deploy.sh` — the latest `main`
- `bash scripts/deploy.sh v1.37.4` — a tagged release
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
If anything fails before the reload, it goes back to the previous version (the running app
isn't touched).
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
set names that match your PM2 apps (`pm2 ls`) and the port you use.
### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL.
@@ -112,6 +139,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
- Feature overview: ./FEATURES.md
## Recent Changes
@@ -138,7 +166,7 @@ Documentation: see Frontend README for environment and scripts.
- **`OptionVariant` model**: event options can now have sub-variants (sizes, colours, ticket types — e.g. Adult/Child/VIP under a single "Ticket" option). Variants have an optional `price` override and their own `stockLimit`.
- **Stock limits**: `stockLimit` added to `EventOption` and `EarlyBirdTier`. A value of `0` means unlimited. All stock counts exclude cancelled registrations.
- **Early-bird concurrency handling**: once a Yoco checkout has been issued at an early-bird price, that price is honoured even if another user simultaneously exhausts the tier's stock. Stock-based forfeiture only applies at registration time (via `resolveOptionPrice`). Deadline expiry at payment time forfeits the price; stock races after the checkout is issued do not.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/yoco-checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **`priceSnapshot` / `appliedTierId`** on `RegistrationOption`: price and tier are snapshotted at registration creation. `computeRegistrationTotalDue` uses `priceSnapshot` as authoritative when present (legacy rows without a snapshot fall back to deadline-only re-evaluation).
- **Cancelled registration tickets**: `GET /api/tickets/mytickets` now excludes tickets from cancelled registrations at the database level.
- **Cancellation payment guard**: `DELETE /api/registrations/:id` blocks non-admins from cancelling a registration that has positive payments. Admins can cancel at any time.
+32 -1
View File
@@ -57,6 +57,20 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `payfast_merchant_id` / `payfast_merchant_key` | `PAYFAST_MERCHANT_ID` / `PAYFAST_MERCHANT_KEY` env vars, else *(blank)* | From the PayFast (or Sandbox) dashboard. Both are needed before PayFast can take payments. The key isn't secret (it's in the payment form), so it's shown in full. Managed through `/api/payfast`. |
| `payfast_passphrase` | `PAYFAST_PASSPHRASE` env var, else *(blank)* | Stored encrypted. Must match the dashboard's salt passphrase exactly, or PayFast refuses payments ("signature does not match"). |
| `payfast_mode` | `PAYFAST_MODE` env var, else `sandbox` | `sandbox` or `live`: where payers are sent and where notifications are confirmed. |
| `payment_providers` | Yoco enabled | JSON `{ order: [...], enabled: { yoco: true } }`: which online payment providers are on, and their order (the first one enabled and set up is the default). Providers not listed yet come last, switched off (Yoco on, as it was the only provider). If none is on and set up, online payments are off. |
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
@@ -65,7 +79,9 @@ or Branding tab has been saved even once — the wizard always writes
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. Only used by the built-in privacy notice: once a version is published in Admin → Privacy, its publish date is the effective date. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
## WhatsApp
@@ -73,6 +89,21 @@ or Branding tab has been saved even once — the wizard always writes
| Setting | Default when unset | Notes |
|---|---|---|
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/whatsapp/webhook?secret=…`). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
## Privacy (POPIA)
| Setting | Default when unset | Notes |
|---|---|---|
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
People's News & announcements opt-in is per account (`User.newsEmail`, `User.newsWhatsApp`), not a site setting. Unsubscribe links are signed with `JWT_SECRET` and don't expire — changing `JWT_SECRET` breaks links in emails already sent.
## Backups
+33 -3
View File
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
# ─── Auth ─────────────────────────────────────────────────────────────────────
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
# Two-factor authentication is required for admins and supervisors whenever
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
# switch, e.g. if the mail server is down and admins are locked out.
# TWO_FACTOR_REQUIRED_ROLES=true
# ─── Server ───────────────────────────────────────────────────────────────────
PORT=5000
NODE_ENV=development
@@ -28,9 +33,21 @@ FRONTEND_URL=http://localhost:3000
# EMAIL_PASS=your_email_password
# EMAIL_FROM=no-reply@example.com
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
YOCO_SECRET_KEY=your_yoco_secret_key
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
# PAYFAST_MERCHANT_ID=
# PAYFAST_MERCHANT_KEY=
# PAYFAST_PASSPHRASE=
# PAYFAST_MODE=sandbox
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
# PAYFAST_SKIP_IP_CHECK=true
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
@@ -47,6 +64,18 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
DAILY_SUMMARY_ENABLED=true
SCHEDULED_EMAILS_ENABLED=true
SCHEDULED_EMAILS_INTERVAL_MS=30000
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
CONSENT_JOBS_ENABLED=true
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
WAITLIST_JOB_ENABLED=true
# WAITLIST_JOB_INTERVAL_MS=600000
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
PAYMENT_STATUS_JOB_ENABLED=true
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
PRIVACY_JOBS_ENABLED=true
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
# accounts are anonymised after 5 years by default (with a warning 30 days before).
RETENTION_JOBS_ENABLED=true
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
@@ -62,6 +91,7 @@ BACKUP_ENABLED=true
# - Registration notification emails
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
# - Yoco secret key and webhook secret (encrypted the same way)
# - Legal page content (operator name, Information Officer, website URL, etc.)
# The first-time setup wizard handles initial configuration on fresh deployments.
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
+432 -17
View File
@@ -18,6 +18,19 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments)
- [Registrations](#registrations)
- [Privacy (POPIA)](#privacy-popia)
- [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)
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -29,7 +42,7 @@ Tokens are issued by `POST /api/users/login`.
- [Sections](#sections)
- [Banner](#banner)
- [Forms](#forms)
- [Yoco Transactions](#yoco-transactions)
- [Provider Transactions](#provider-transactions)
- [WhatsApp Admin](#whatsapp-admin)
- [Webhooks](#webhooks)
- [Settings](#settings)
@@ -43,7 +56,9 @@ Tokens are issued by `POST /api/users/login`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/users` | public | Register new account |
| POST | `/api/users/login` | public | Login, returns JWT |
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
| POST | `/api/users/forgot` | public | Request password reset email |
| POST | `/api/users/reset` | public | Reset password with token |
| POST | `/api/users/activate` | public | Activate account from email link |
@@ -51,9 +66,13 @@ Tokens are issued by `POST /api/users/login`.
| PUT | `/api/users/profile` | user+ | Update own profile |
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
| POST | `/api/users/close-account` | user+ | Deactivate own account |
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
| GET | `/api/users/:id` | admin | Get user by ID |
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
@@ -71,10 +90,32 @@ Returns: `{ _id, name, email, role, token }`
### POST /api/users/login
```json
{ "email": "jane@example.com", "password": "securepass123" }
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
```
Returns: `{ _id, name, email, role, token }`
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
```json
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
```
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
### POST /api/users/login/2fa
```json
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
```
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
### Two-factor setup (`/api/users/2fa/*`)
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
### PUT /api/users/profile
```json
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
@@ -199,7 +240,11 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| GET | `/api/registrations` | staff+ | All registrations |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
### POST /api/registrations
```json
@@ -211,6 +256,16 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
]
}
```
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
- Form responses can carry `attendeeId`.
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
For unauthenticated (guest) registration on events with `requiresAuth: false`:
```json
{
@@ -233,12 +288,352 @@ 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 |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
How the stages are recorded:
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
- `paid` is read from that registration's status.
- Counts are sessions that reached a stage or a later one.
Data handling:
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
## Post-event feedback
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null |
| PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) |
| DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form |
| POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) |
| GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once |
| GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
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; `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
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
How the money is handled:
- A spot costs the cheapest paid main ticket.
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
Other changes:
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
## Rooms & groups
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
Related changes:
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
## Ticket transfer
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
What a transfer does:
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
- An account is matched by email/phone, or created inactive with an activation link.
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
Old QR codes:
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
- Offline sync reports them as `transferred`.
- Each transfer is audited as `ticket_transferred`.
## Offline scanning
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
How locks work:
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
How syncing works:
- Synced scans never redeem more than is left on a ticket.
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
- 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. An `all` option in a pick-mode event may also send
`sessionIds`: it stays one line and one ticket at one price, with the sessions kept in
`RegistrationOption.sessionIds` (empty = every session). That ticket only scans on those
sessions, and capacity, reminders, the register and certificates follow them; adding more
of the same ticket to a registration merges the sessions. 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.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, sessionId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity. `sessionId` is required for a pick-sessions option (one session per entry) and refused otherwise |
| GET | `/api/waitlist/mine` | user+ | My active entries |
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers (with `sessionId`, `sessionName`, `lineName` for a per-session offer; `eventStart` is the session's start) |
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
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; `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.
- Pick-sessions options are waited for per session: free places are that session's (stock per session and session capacity), an offer expires no later than the session's start, and sessions that started or were cancelled aren't offered. For main tickets, free places also take session capacity into account, and held places count towards it.
## Parental consent
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
How requests work:
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
Check-in rules:
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
## Privacy (POPIA)
The data subject's tools, unsubscribe links and the admin data-requests inbox (`src/utils/privacy.js`, `src/controllers/privacyController.js`).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/privacy/me` | user | `{ news: { email, whatsapp }, newsAskedAt, canWhatsApp, notificationPreference, history, requests }` |
| PUT | `/api/privacy/me/news` | user | `{ email?: bool, whatsapp?: bool, source?: "prompt" }` — change the news opt-in (WhatsApp needs a valid phone). Logged in `CommunicationConsent`; `source: "prompt"` is the one-time question after login |
| GET | `/api/privacy/me/export` | user | Download my data as JSON (`?format=pdf` for a readable PDF). Recorded as a completed self-service `access` request. 403 while impersonating; rate-limited |
| POST | `/api/privacy/me/requests` | user | `{ type: "correction", details }` or `{ type: "deletion", password, details? }`. Deletion closes the account at once (deactivated, sessions revoked, news off). 409 if one of that type is already open. Emails the Information Officer + admins |
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/accessLog.js`, `src/controllers/privacyAdminController.js`):
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/privacy/notice?version=N` | public | The notice (current, or version N) with placeholders filled in: `{ version, builtIn, current, effectiveDate, changeSummary, sections: [{ id, title, body }], versions }`. Version 0 = the built-in notice, only while nothing is published |
| POST | `/api/privacy/me/notice/accept` | user | Accept the current version (recorded in `NoticeAcceptance`, `User.noticeVersionAccepted`). 403 while impersonating |
| GET | `/api/privacy/notice/draft` | admin | Editor state: `sections` (raw, with placeholders), `rendered`, `isDraft`, `savedAt`, `versions` (with acceptance counts), `builtIn` (the built-in sections), `placeholders` |
| PUT | `/api/privacy/notice/draft` | admin | `{ sections }` — save the draft (AppSetting `privacy_notice_draft`) |
| DELETE | `/api/privacy/notice/draft` | admin | Discard the draft |
| POST | `/api/privacy/notice/publish` | admin | `{ sections, changeSummary?, requiresAcceptance? }` — publish the next version and clear the draft. `requiresAcceptance` needs a `changeSummary`. Audited as `privacy_notice_published` |
| GET / POST | `/api/privacy/operators` | admin | The operator register / add one `{ name, purpose, dataShared?, location?, website?, active? }` |
| PUT / DELETE | `/api/privacy/operators/:id` | admin | Change / remove an operator (audited as `operator_*`) |
| GET | `/api/privacy/consent-log?type=news\|notice\|parental&q=&page=` | admin | News opt-in changes, notice acceptances, or parental consent answers (50 per page) |
| GET | `/api/privacy/access-log?action=&actorId=&from=&to=&page=` | admin | Bulk exports/views of personal information, with `actions` (labels) and `actors` for the filters |
| GET | `/api/privacy/readiness` | admin | `{ score, passed, total, checks: [{ key, label, ok, detail, href, applies }] }` |
| GET | `/api/privacy/retention` | admin | `{ policies, definitions, reviewedAt, reviewedBy, preview, lastRun, related }` — `preview` is what the next run would affect |
| PUT | `/api/privacy/retention` | admin | `{ policies: { inactive_accounts\|form_answers\|login_history\|payment_details\|audit_logs: { enabled, months } } }` — all five required. Audited as `retention_policies_updated` |
| POST | `/api/privacy/retention/run` | admin | Run the retention job now: `{ result, state }` |
| GET / POST | `/api/privacy/breaches` | admin | The breach register / record one `{ title, discoveredAt, description, dataAffected, occurredAt?, peopleAffected?, whoAffected?, consequences?, actionsTaken?, adviceToPeople?, causedBy? }` |
| PUT | `/api/privacy/breaches/:id` | admin | Change any of those, `status` (open\|contained\|closed), `regulatorNotifiedAt`, `peopleNotifiedAt`. Audited as `breach_updated` |
The `retention` job (03:30, `RETENTION_JOBS_ENABLED=false` to disable) works through each switched-on policy. Inactive accounts: role `user`, not anonymised, whose latest of created / last login (`User.lastLoginAt`) / registration or its event's end / named as attendee / payment is older than the period. Those within 30 days of it are warned (email, else WhatsApp; `User.inactivityWarnedAt`), and those warned at least 30 days ago — after their last activity — are anonymised unless they have a registration for an upcoming event. Logging in clears the warning. At most 300 warnings and 200 anonymisations per run. Payment details: `ProviderTransaction.raw` and `ProviderCheckout.request` older than the period are scrubbed and marked `_scrubbed`. Access log rows carry `sensitive` (medical lists, and anything for an event with `requiresParentalConsent`); `?sensitive=true` filters them.
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and — each on a line of its own — `{operators}` (one bullet per active operator) and `{retention}` (one bullet per retention period). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
Access log actions (`DataAccessLog.action`): `report_pdf`, `report_excel`, `report_email`, `report_whatsapp` (`/api/reports/*`, with the row count), `attendance_export`, `certificates_download`, `group_lists` (or `medical_list` with `?medical=true`), `form_responses` (first page of a list), `consent_list`, `medical_list`, `backup_download`, `impersonation`.
Notes:
- Requests are due 30 days after they're made. The `privacy` job (08:00, `PRIVACY_JOBS_ENABLED=false` to disable) emails the Information Officer (`legal_io_email`) and admins about open requests due within 7 days or overdue.
- **Anonymising** (also `POST /api/users/:id/anonymize`) keeps payments, registrations, tickets and check-ins, and removes: the account's name/email/phone/password, the details of attendees on their registrations and their own attendee rows elsewhere, parental-consent details, form answers and drafts, feedback authorship and invites, waiting waitlist places, pending transfer requests, names on transfers and certificates, sponsor names/messages, login history, password resets and 2FA challenges, and payer fields in provider webhook payloads and checkouts.
- Sign-up (`POST /api/users`) takes `newsOptIn: bool`. Login and `GET /api/users/profile` return `newsPromptPending` (never answered the opt-in question).
## Coupons
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
| POST | `/api/coupons` | supervisor+ | Create a code |
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
## Payments
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) |
| POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
| POST | `/api/payments/checkout` | user+ | Start an online checkout (also `/api/payments/yoco-checkout`); `provider` optional (default: first enabled provider); at least R15; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
| POST | `/api/payments/checkout/send` | supervisor+ | Send a generated payment link by email or WhatsApp (also `/api/payments/yoco-checkout/send`) |
| GET | `/api/payments/providers` | public | Providers people can pay with now (enabled and set up), in order: `{ providers: [{ id, label }], minimumAmount: 15 }` |
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) |
| GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` |
| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` |
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
@@ -258,7 +653,7 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
}
```
### POST /api/payments/yoco-checkout
### POST /api/payments/checkout
```json
{
"registrationId": "reg-uuid-...",
@@ -311,6 +706,8 @@ Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if ear
Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`
**Message kind (POPIA).** Every bulk send — these broadcasts, WhatsApp broadcasts, and `POST /api/events/:id/email-attendees` / `whatsapp-attendees` (and their `/schedule`) — takes `kind`: `"update"` or `"news"`. Broadcasts default to `news`, attendee messages to `update`; `promoEventId` (the next-event promo automation) forces `news`, `template: "tickets"` forces `update`. `news` goes only to accounts opted in on that channel (`User.newsEmail` / `newsWhatsApp`) and skips addresses in `MessagingOptOut`; it gets an unsubscribe link (email: plus `List-Unsubscribe` / `List-Unsubscribe-Post` headers; WhatsApp: "Reply STOP"). `update` emails get a footer saying why they were sent. Responses (and dry runs) include `kind` and `skipped`. See [Privacy](#privacy-popia).
---
## WhatsApp Broadcasts
@@ -407,16 +804,16 @@ Groups of event options shown as labelled sections on the registration page (e.g
---
## Yoco Transactions
## Provider Transactions
Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically.
Transactions payment providers sent by webhook (`ProviderTransaction`, one per provider + external id). Used for reconciling payments that weren't matched automatically. Each item has `provider`, `providerLabel` and the checkout `metadata`. Also served at `/api/yoco-transactions` (the older name).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco-transactions` | supervisor+ | All Yoco webhook transactions |
| GET | `/api/yoco-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment |
| POST | `/api/yoco-transactions/:id/reconcile` | supervisor+ | Manually link transaction to a registration |
| POST | `/api/yoco-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
| GET | `/api/provider-transactions` | supervisor+ | All transactions (`?provider=yoco&reconciled=&ignored=`) |
| GET | `/api/provider-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment (`?provider=`) |
| POST | `/api/provider-transactions/:id/reconcile` | supervisor+ | Record it as a payment on a registration (`{ registrationId }`) or a donation (`{ eventId, userId? }`); the payment gets the transaction's provider |
| POST | `/api/provider-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
---
@@ -426,7 +823,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials |
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials, and `webhookUrl` (with its secret) to paste into WAWP |
| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
| GET | `/api/whatsapp/status` | admin | WAWP session status |
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
@@ -443,14 +840,16 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets |
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
| POST | `/api/whatsapp/webhook?secret=…` | secret in URL | WAWP events (the URL to paste into WAWP is on `GET /api/whatsapp/config` as `webhookUrl`). `session.status` FAILED → automatic session recovery (works without the secret). `message` → a STOP/unsubscribe reply turns off WhatsApp news for that number and sends a confirmation; only with a matching secret, since WAWP doesn't sign webhooks. Senders identified only by a WhatsApp LID (no phone number) are ignored |
| POST | `/api/webhooks/whatsapp` | — | Old placeholder, does nothing — use `/api/whatsapp/webhook` |
---
## Settings
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
@@ -462,6 +861,22 @@ For what each individual setting key defaults to when it's never been saved, see
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
## Yoco
Managed from Admin → Site Settings → Payments.
PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping.
Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/<checkoutId>` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
### POST /api/settings/test-smtp
```json
{
+191
View File
@@ -0,0 +1,191 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
---
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
```
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
```bash
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
```
It takes about five seconds and is safe to re-run as often as you like. Each run wipes the
previous demo and creates it again.
### Before you run it
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
To keep a different account, set `DEMO_ADMIN_EMAIL`:
```bash
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
```
If the account isn't found, the script stops before changing anything.
- Migrations must be up to date (`npm run prisma:deploy`).
---
## What it keeps and what it clears
| Kept | Cleared |
|---|---|
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
| The admin account, including its login history, trusted devices and 2FA | Every other user |
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
The wipe and the new data are written in one database transaction. If that fails,
nothing changes. The past events' cashups run straight afterwards, through the same
code as the cashup screen. If one of those fails, re-run the script.
---
## What it creates
### Upcoming events
All dates are worked out from the day you run it, so these events are always upcoming.
Registrations are spread over the past month, as if sign-ups have been coming in.
The data is generated from a fixed seed, so every run looks the same.
| When (from today) | Event | Shows off |
|---|---|---|
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations) and a **pick-any** deal (Lunch for four: any 4 lunch packs for R220), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
Each event also gets:
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
(WhatsApp, Facebook, Instagram, email, direct) and device.
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
### Past events
Ten completed events spread over the past year, for showing cashups and the historical
reports. Each one has:
- Online sales in the five weeks before the event, with a few unpaid no-shows.
- **Door sales** on the day, recorded by the staff team (cash and card machine).
- **Check-ins**: about 90% of tickets scanned, with the odd group arriving short.
- **Staff cash counts** with note and coin denominations.
Some events also have refunds, donations and post-event feedback.
The cashups cover every path through the cashup screen:
| When | Event | Cashup to demo |
|---|---|---|
| ~11 months ago | **Church Family Picnic** | **Quick closed** (no settlement) |
| 24 Dec | **Christmas Eve Candlelight Service** | Free event with card and cash **donations**. Card settled net of **Yoco fees**, collection banked |
| Easter Sunday | **Easter Sunrise Service & Breakfast** | Cash banked less a **cash deposit fee** (bank charge), card fees, a **refund** |
| Mother's Day | **Mother's Day Brunch** | **Closed with R50 cash short → reopened → recounted → closed again.** The history shows all three steps |
| ~3½ months ago | **Youth Winter Camp** | Deposits and balances. **EFT pending** (POPs) at close, later **marked received** without reopening |
| ~2½ months ago | **Men's Golf Day** | Card-heavy, card fees written off, a refund |
| ~2 months ago | **Kids Holiday Club** | Cash-heavy door sales. **R20 cash shortfall written off**, **R500 kept on hand** as float |
| ~6 weeks ago | **Women's High Tea** | Closed with **R30 unaccounted**, shown in red |
| ~4 weeks ago | **Missions Fundraiser Dinner** | **R2 600 EFT still pending** (POP received, not cleared). Shows "Mark received" / "Write off" |
| ~10 days ago | **Worship Night** | **Still open, with the cash already counted.** Use it to do a cashup live in the demo |
Easter, the Youth Camp, the Holiday Club and the High Tea have **feedback forms** with
ratings and comments. Funnel stats older than 90 days are stored as daily totals, the
way the app rolls them up overnight, so the funnel report covers the whole year.
### Totals
A typical run gives roughly:
| | |
|---|---|
| Users | 130 (plus the admin) |
| Registrations | ~700 (paid, part-paid, pending, cancelled and refunded) |
| Payments | ~640, including donations, sponsorships and refunds |
| Tickets | ~780, with ~490 check-ins |
| Named campers | ~45 |
| Feedback responses | ~80 |
| Funnel rows | ~4 400 |
---
## Logging in
Every demo account uses the password **`Demo@1234`**.
| Role | Email | Good for showing |
|---|---|---|
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, coupons, viewing cashups |
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
| Staff | `anri.botha@example.com` | |
| Staff | `daniel.pillay@example.com` | |
Closing, reopening and settling pending money in a cashup are admin-only, so do the
live Worship Night cashup as your own admin account.
The other 126 people are ordinary members (role `user`), with emails like
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
registrations, tickets and payments. Any registrant's email from an event's attendee
list works.
Your own admin account keeps its existing password.
---
## Safety
- **Nothing is sent.** The script writes straight to the database and never calls the
email, WhatsApp or Yoco code.
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
(a reserved domain that can't receive mail) and have **no phone numbers**, so
reminders or broadcasts sent during a demo go nowhere. All parental consents are
already signed, so no consent reminders go out.
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
the demo site's Yoco settings in test mode or empty.
---
## Customising
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
`main()` decides which ones run. To change an event's name, prices, ticket types or
number of registrations, edit its function. To add an event, write a new `build…()`
and add it to the list in `main()`. `addBundle(event, { name, price }, [[option, quantity], …])`
adds a bundle deal. Registrations added after it get the deal automatically, worked out
the same way as the app does it.
The past events are in `buildPastEvents()`, one spec each: dates, ticket types, costs,
how many people bought online and at the door, and a `cashup` that says how it was
closed. Supported closes:
- `'quick'`
- `'full'`, with `lines` saying where the money ended up
- `'reclose'`
- `'none'`, which leaves the event open
A full close can add `later` to settle pending money after closing. The helpers
`cardLine`, `eftLine` and `cashLine` build the usual settlements: card net of Yoco fees,
EFT with an optional pending POP, and cash banked with an optional float, deposit fee or
shortfall.
+21 -4
View File
@@ -79,8 +79,8 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
@@ -99,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
>
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
>
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
---
@@ -141,6 +143,10 @@ npm run prisma:status
npm run prisma:generate
```
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
---
## Running the Server
@@ -333,6 +339,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
#### Yoco — `/api/yoco`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
#### Setup — `/api/setup`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -500,6 +515,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
## Payments (Yoco)
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
1. Frontend calls `POST /api/payments/yoco-checkout`
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
@@ -524,6 +541,6 @@ pm2 save && pm2 startup
- [ ] `JWT_SECRET` is long (32+ chars) and secret
- [ ] `FRONTEND_URL` lists only production origins
- [ ] `NODE_ENV=production`
- [ ] Yoco webhook registered and HTTPS
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
- [ ] WAWP credentials saved via admin panel
- [ ] SMTP credentials correct
+2738 -937
View File
File diff suppressed because it is too large Load Diff
+21 -8
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.10.5",
"version": "1.40.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"dev": "node --watch src/index.js",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
@@ -13,14 +13,17 @@
"prisma:status": "prisma migrate status",
"prisma:reset": "prisma migrate reset --force",
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
"set-demo": "node scripts/set-demo.js"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
"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",
@@ -29,18 +32,28 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"node-fetch": "^2.7.0",
"nodemailer": "^9.0.6",
"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"
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
"prisma": "^7.10.1"
},
"overrides": {
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"@istanbuljs/load-nyc-config": {
"js-yaml": "^4.1.1"
},
"follow-redirects": "^1.16.1"
},
"allowScripts": {
"prisma@7.10.1": true,
"@prisma/engines@7.10.1": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
}
}
+16
View File
@@ -0,0 +1,16 @@
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
require('dotenv/config');
const { defineConfig } = require('prisma/config');
module.exports = defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
// process.env rather than env() so `prisma generate` still works on a
// machine with no DATABASE_URL (generate never connects).
url: process.env.DATABASE_URL,
},
});
@@ -0,0 +1,62 @@
-- CreateEnum
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
-- 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 "SecurityEventType" ADD VALUE 'two_factor_enabled';
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
-- CreateTable
CREATE TABLE "TwoFactorChallenge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"method" "TwoFactorMethod" NOT NULL,
"codeHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrustedDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
-- CreateIndex
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
-- AddForeignKey
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,164 @@
-- CreateEnum
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
-- 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 'coupon_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Coupon" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"type" "CouponType" NOT NULL,
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"maxUses" INTEGER,
"maxUsesPerUser" INTEGER DEFAULT 1,
"minQuantity" INTEGER,
"restrictRoles" "UserRole"[],
"restrictDomain" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponEvent" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponOption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponUser" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponRedemption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"discountAmount" DOUBLE PRECISION NOT NULL,
"appliedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"releasedAt" TIMESTAMP(3),
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
-- CreateIndex
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
-- CreateIndex
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
-- CreateIndex
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
-- CreateIndex
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
-- CreateIndex
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
-- CreateIndex
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
-- CreateIndex
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
-- CreateIndex
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
-- AddForeignKey
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
ADD COLUMN "holderUserId" TEXT;
-- CreateTable
CREATE TABLE "Attendee" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"registrationOptionId" TEXT,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"userId" TEXT,
"dateOfBirth" TIMESTAMP(3),
"gender" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
-- CreateIndex
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
-- CreateIndex
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
-- CreateIndex
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,71 @@
-- CreateEnum
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
-- 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 'consent_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
-- AlterTable
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
ADD COLUMN "parentName" TEXT,
ADD COLUMN "parentPhone" TEXT;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentText" TEXT,
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ConsentRequest" (
"id" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
"textVersion" INTEGER NOT NULL,
"consentText" TEXT NOT NULL,
"parentName" TEXT,
"parentEmail" TEXT,
"parentPhone" TEXT,
"signedName" TEXT,
"ip" TEXT,
"device" TEXT,
"medical" TEXT,
"emergency" TEXT,
"photoConsent" BOOLEAN,
"respondedAt" TIMESTAMP(3),
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remindersSent" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"medicalPurgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
-- CreateIndex
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"userId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"position" INTEGER NOT NULL,
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
"offeredQuantity" INTEGER,
"offerTokenHash" TEXT,
"offeredAt" TIMESTAMP(3),
"offerExpiresAt" TIMESTAMP(3),
"claimedAt" TIMESTAMP(3),
"registrationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
-- CreateIndex
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,93 @@
-- 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 'scan_lock_acquired';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "scanLockId" TEXT,
ADD COLUMN "syncedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "ScanLock" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sectionId" TEXT,
"deviceId" TEXT NOT NULL,
"deviceName" TEXT,
"userId" TEXT NOT NULL,
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshotAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"releasedAt" TIMESTAMP(3),
"forceReleasedById" TEXT,
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OfflineSyncReport" (
"id" TEXT NOT NULL,
"scanLockId" TEXT NOT NULL,
"accepted" INTEGER NOT NULL DEFAULT 0,
"duplicates" INTEGER NOT NULL DEFAULT 0,
"conflicts" JSONB NOT NULL DEFAULT '[]',
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedById" TEXT,
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
-- CreateIndex
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
-- CreateIndex
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
-- CreateIndex
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
-- CreateIndex
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "TicketTransfer" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"fromHolderName" TEXT NOT NULL,
"fromUserId" TEXT,
"toName" TEXT NOT NULL,
"toUserId" TEXT,
"byUserId" TEXT,
"oldQrCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
-- CreateIndex
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,78 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "collectGender" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "AllocationSet" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"showOnTicket" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AllocationSet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AllocationGroup" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"capacity" INTEGER,
"gender" TEXT,
"leaderUserId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AllocationGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AllocationAssignment" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AllocationAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AllocationSet_eventId_idx" ON "AllocationSet"("eventId");
-- CreateIndex
CREATE INDEX "AllocationGroup_setId_idx" ON "AllocationGroup"("setId");
-- CreateIndex
CREATE INDEX "AllocationGroup_leaderUserId_idx" ON "AllocationGroup"("leaderUserId");
-- CreateIndex
CREATE INDEX "AllocationAssignment_groupId_idx" ON "AllocationAssignment"("groupId");
-- CreateIndex
CREATE INDEX "AllocationAssignment_attendeeId_idx" ON "AllocationAssignment"("attendeeId");
-- CreateIndex
CREATE UNIQUE INDEX "AllocationAssignment_setId_attendeeId_key" ON "AllocationAssignment"("setId", "attendeeId");
-- AddForeignKey
ALTER TABLE "AllocationSet" ADD CONSTRAINT "AllocationSet_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_leaderUserId_fkey" FOREIGN KEY ("leaderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "sponsorshipGoalSpots" INTEGER;
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sponsorMessage" TEXT,
ADD COLUMN "sponsorName" TEXT;
@@ -0,0 +1,122 @@
-- 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 "FormFieldType" ADD VALUE 'rating';
ALTER TYPE "FormFieldType" ADD VALUE 'multiple_choice';
-- CreateTable
CREATE TABLE "FeedbackForm" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sendAt" TIMESTAMP(3),
"audience" TEXT NOT NULL DEFAULT 'checked_in',
"anonymous" BOOLEAN NOT NULL DEFAULT false,
"intro" TEXT,
"sentAt" TIMESTAMP(3),
"reminderSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeedbackForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"type" "FormFieldType" NOT NULL,
"label" TEXT NOT NULL,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"helpText" TEXT,
"options" JSONB,
CONSTRAINT "FeedbackField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackInvite" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remindedAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
CONSTRAINT "FeedbackInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackResponse" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FeedbackResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackAnswer" (
"id" TEXT NOT NULL,
"responseId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "FeedbackAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackForm_eventId_key" ON "FeedbackForm"("eventId");
-- CreateIndex
CREATE INDEX "FeedbackField_formId_idx" ON "FeedbackField"("formId");
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackInvite_tokenHash_key" ON "FeedbackInvite"("tokenHash");
-- CreateIndex
CREATE INDEX "FeedbackInvite_userId_idx" ON "FeedbackInvite"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackInvite_formId_userId_key" ON "FeedbackInvite"("formId", "userId");
-- CreateIndex
CREATE INDEX "FeedbackResponse_formId_idx" ON "FeedbackResponse"("formId");
-- CreateIndex
CREATE INDEX "FeedbackAnswer_responseId_idx" ON "FeedbackAnswer"("responseId");
-- CreateIndex
CREATE INDEX "FeedbackAnswer_fieldId_idx" ON "FeedbackAnswer"("fieldId");
-- AddForeignKey
ALTER TABLE "FeedbackForm" ADD CONSTRAINT "FeedbackForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackField" ADD CONSTRAINT "FeedbackField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FeedbackResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "FeedbackField"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsDaily" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"day" DATE NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'direct',
"device" TEXT NOT NULL DEFAULT 'unknown',
"sessions" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
-- CreateIndex
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
-- AddForeignKey
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
-- 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 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- 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 'yoco_config_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "CashupWriteOffCategory" AS ENUM ('card_fees', 'bank_charges', 'cash_shortfall', 'other');
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "bankAmount" DOUBLE PRECISION,
ADD COLUMN "onHandAmount" DOUBLE PRECISION,
ADD COLUMN "pendingAmount" DOUBLE PRECISION,
ADD COLUMN "pendingReference" TEXT,
ADD COLUMN "writeOffTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "EventCashupWriteOff" (
"id" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"category" "CashupWriteOffCategory" NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventCashupWriteOff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPendingResolution" (
"id" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"category" "CashupWriteOffCategory",
"notes" TEXT,
"performedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventCashupPendingResolution_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupWriteOff_lineId_idx" ON "EventCashupWriteOff"("lineId");
-- CreateIndex
CREATE INDEX "EventCashupPendingResolution_lineId_idx" ON "EventCashupPendingResolution"("lineId");
-- CreateIndex
CREATE INDEX "EventCashupPendingResolution_performedById_idx" ON "EventCashupPendingResolution"("performedById");
-- AddForeignKey
ALTER TABLE "EventCashupWriteOff" ADD CONSTRAINT "EventCashupWriteOff_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,83 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_deleted';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "bundleDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "EventBundle" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventBundle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventBundleItem" (
"id" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"quantity" INTEGER NOT NULL,
CONSTRAINT "EventBundleItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationBundle" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"discount" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationBundle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventBundle_eventId_idx" ON "EventBundle"("eventId");
-- CreateIndex
CREATE INDEX "EventBundleItem_bundleId_idx" ON "EventBundleItem"("bundleId");
-- CreateIndex
CREATE INDEX "EventBundleItem_eventOptionId_idx" ON "EventBundleItem"("eventOptionId");
-- CreateIndex
CREATE INDEX "EventBundleItem_variantId_idx" ON "EventBundleItem"("variantId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_registrationId_idx" ON "RegistrationBundle"("registrationId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_bundleId_idx" ON "RegistrationBundle"("bundleId");
-- AddForeignKey
ALTER TABLE "EventBundle" ADD CONSTRAINT "EventBundle_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "BundleKind" AS ENUM ('exact', 'mix', 'free', 'group');
-- CreateEnum
CREATE TYPE "BundleGroupPricing" AS ENUM ('per_ticket', 'percent');
-- AlterTable
ALTER TABLE "EventBundle" ADD COLUMN "freeCount" INTEGER,
ADD COLUMN "groupPricing" "BundleGroupPricing",
ADD COLUMN "kind" "BundleKind" NOT NULL DEFAULT 'exact',
ADD COLUMN "setSize" INTEGER;
@@ -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';
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "WaitlistEntry" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "WaitlistEntry_sessionId_idx" ON "WaitlistEntry"("sessionId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,23 @@
-- Payment providers (1.36.0). YocoTransaction becomes ProviderTransaction: renamed in place
-- so every stored transaction is kept (Prisma's generated plan drops and recreates it).
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'payment_providers_updated';
-- Rename YocoTransaction → ProviderTransaction
ALTER TABLE "YocoTransaction" RENAME TO "ProviderTransaction";
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_pkey" TO "ProviderTransaction_pkey";
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_paymentId_fkey" TO "ProviderTransaction_paymentId_fkey";
ALTER INDEX "YocoTransaction_paymentId_idx" RENAME TO "ProviderTransaction_paymentId_idx";
-- Which provider sent it; external ids are unique per provider
ALTER TABLE "ProviderTransaction" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'yoco';
DROP INDEX "YocoTransaction_externalId_key";
CREATE UNIQUE INDEX "ProviderTransaction_provider_externalId_key" ON "ProviderTransaction"("provider", "externalId");
-- Payment.provider: every online payment so far came through Yoco (linked to a transaction,
-- or the donation split off one)
ALTER TABLE "Payment" ADD COLUMN "provider" TEXT;
UPDATE "Payment" SET "provider" = 'yoco'
WHERE "id" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL)
OR "originalPaymentId" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL);
@@ -0,0 +1,24 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'payfast_config_updated';
-- AlterTable
ALTER TABLE "ProviderTransaction" ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "ProviderCheckout" (
"id" TEXT NOT NULL,
"provider" TEXT,
"externalId" TEXT,
"redirectUrl" TEXT,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ZAR',
"metadata" JSONB NOT NULL,
"request" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderCheckout_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderCheckout_provider_externalId_idx" ON "ProviderCheckout"("provider", "externalId");
@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "DataRequestType" AS ENUM ('access', 'correction', 'deletion');
-- CreateEnum
CREATE TYPE "DataRequestStatus" AS ENUM ('open', 'completed', 'rejected', 'cancelled');
-- 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 'data_request_completed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'data_request_rejected';
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_anonymised';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "newsAskedAt" TIMESTAMP(3),
ADD COLUMN "newsEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "newsWhatsApp" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "CommunicationConsent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"address" TEXT,
"channel" TEXT NOT NULL,
"granted" BOOLEAN NOT NULL,
"source" TEXT NOT NULL,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommunicationConsent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessagingOptOut" (
"id" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"address" TEXT NOT NULL,
"source" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MessagingOptOut_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DataRequest" (
"id" TEXT NOT NULL,
"type" "DataRequestType" NOT NULL,
"status" "DataRequestStatus" NOT NULL DEFAULT 'open',
"userId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"details" TEXT,
"response" TEXT,
"selfService" BOOLEAN NOT NULL DEFAULT false,
"dueAt" TIMESTAMP(3) NOT NULL,
"closedAt" TIMESTAMP(3),
"handledById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DataRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommunicationConsent_userId_createdAt_idx" ON "CommunicationConsent"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "CommunicationConsent_address_idx" ON "CommunicationConsent"("address");
-- CreateIndex
CREATE UNIQUE INDEX "MessagingOptOut_channel_address_key" ON "MessagingOptOut"("channel", "address");
-- CreateIndex
CREATE INDEX "DataRequest_status_dueAt_idx" ON "DataRequest"("status", "dueAt");
-- CreateIndex
CREATE INDEX "DataRequest_userId_idx" ON "DataRequest"("userId");
-- AddForeignKey
ALTER TABLE "CommunicationConsent" ADD CONSTRAINT "CommunicationConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Existing accounts: kept opted in to news (WhatsApp only when they have a phone number),
-- logged as "existing_account", and asked once at their next login (newsAskedAt stays null).
UPDATE "User" SET "newsEmail" = true, "newsWhatsApp" = ("phoneNumber" IS NOT NULL)
WHERE "isActive" = true AND "email" NOT LIKE '%@deleted.invalid' AND "email" NOT LIKE '%@deleted.local' AND "email" NOT LIKE '%@guest.local';
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
SELECT gen_random_uuid()::text, "id", 'email', true, 'existing_account' FROM "User" WHERE "newsEmail" = true;
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
SELECT gen_random_uuid()::text, "id", 'whatsapp', true, 'existing_account' FROM "User" WHERE "newsWhatsApp" = true;
@@ -0,0 +1,110 @@
-- 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 'privacy_notice_published';
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_deleted';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "noticeVersionAccepted" INTEGER;
-- CreateTable
CREATE TABLE "PrivacyNotice" (
"id" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"sections" JSONB NOT NULL,
"changeSummary" TEXT,
"requiresAcceptance" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"publishedById" TEXT,
CONSTRAINT "PrivacyNotice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NoticeAcceptance" (
"id" TEXT NOT NULL,
"userId" TEXT,
"version" INTEGER NOT NULL,
"source" TEXT NOT NULL,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NoticeAcceptance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Operator" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"dataShared" TEXT NOT NULL,
"location" TEXT,
"website" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Operator_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DataAccessLog" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"actorRole" TEXT NOT NULL,
"action" TEXT NOT NULL,
"what" TEXT NOT NULL,
"eventId" TEXT,
"records" INTEGER,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataAccessLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PrivacyNotice_version_key" ON "PrivacyNotice"("version");
-- CreateIndex
CREATE INDEX "NoticeAcceptance_userId_createdAt_idx" ON "NoticeAcceptance"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "NoticeAcceptance_version_idx" ON "NoticeAcceptance"("version");
-- CreateIndex
CREATE INDEX "DataAccessLog_createdAt_idx" ON "DataAccessLog"("createdAt");
-- CreateIndex
CREATE INDEX "DataAccessLog_actorId_createdAt_idx" ON "DataAccessLog"("actorId", "createdAt");
-- CreateIndex
CREATE INDEX "DataAccessLog_action_createdAt_idx" ON "DataAccessLog"("action", "createdAt");
-- AddForeignKey
ALTER TABLE "PrivacyNotice" ADD CONSTRAINT "PrivacyNotice_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoticeAcceptance" ADD CONSTRAINT "NoticeAcceptance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataAccessLog" ADD CONSTRAINT "DataAccessLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- The operator register starts with the services Hope Events itself uses. Admins correct the
-- details (e.g. which email and hosting provider) in Admin → Privacy → Operators.
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
(gen_random_uuid()::text, 'Yoco', 'Card payments', 'Payment amount and reference; card details are entered on Yoco''s own page', 'South Africa', 'https://www.yoco.com', true, 1, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'PayFast', 'Online payments', 'Name, email address, payment amount and reference', 'South Africa', 'https://payfast.io', true, 2, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'WAWP', 'Delivering WhatsApp messages', 'Mobile number and message content', NULL, 'https://wawp.net', true, 3, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'WhatsApp (Meta Platforms)', 'The WhatsApp messaging service', 'Mobile number and message content', NULL, 'https://www.whatsapp.com/legal/privacy-policy', true, 4, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'Email delivery provider', 'Sending emails (the SMTP server in Site settings)', 'Name, email address and message content', NULL, NULL, true, 5, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'Hosting provider', 'The servers this site and its database run on', 'All information held by the site', 'South Africa', NULL, true, 6, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'Google Maps', 'Maps on event pages', 'Your device''s IP address and browser details when a map loads', NULL, 'https://policies.google.com/privacy', true, 7, CURRENT_TIMESTAMP),
(gen_random_uuid()::text, 'Sentry', 'Error monitoring (only when it is set up)', 'Technical error reports, which can include the signed-in user''s id', NULL, 'https://sentry.io', false, 8, CURRENT_TIMESTAMP);
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "BreachStatus" AS ENUM ('open', 'contained', 'closed');
-- 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 'retention_policies_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'retention_run';
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_recorded';
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_updated';
-- AlterTable
ALTER TABLE "DataAccessLog" ADD COLUMN "sensitive" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "inactivityWarnedAt" TIMESTAMP(3),
ADD COLUMN "lastLoginAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "SecurityBreach" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "BreachStatus" NOT NULL DEFAULT 'open',
"discoveredAt" TIMESTAMP(3) NOT NULL,
"occurredAt" TIMESTAMP(3),
"description" TEXT NOT NULL,
"dataAffected" TEXT NOT NULL,
"peopleAffected" INTEGER,
"whoAffected" TEXT,
"consequences" TEXT,
"actionsTaken" TEXT,
"adviceToPeople" TEXT,
"causedBy" TEXT,
"regulatorNotifiedAt" TIMESTAMP(3),
"peopleNotifiedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SecurityBreach_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SecurityBreach_status_discoveredAt_idx" ON "SecurityBreach"("status", "discoveredAt");
-- AddForeignKey
ALTER TABLE "SecurityBreach" ADD CONSTRAINT "SecurityBreach_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Last login, from the login history (which the retention job will start expiring).
UPDATE "User" u SET "lastLoginAt" = l.at
FROM (SELECT "userId", MAX("createdAt") AS at FROM "SecurityEvent" WHERE "type" = 'login' AND "userId" IS NOT NULL GROUP BY "userId") l
WHERE u."id" = l."userId";
+1255 -239
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
// Load .env from the backend root
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient } = require('../src/config/db');
const crypto = require('crypto');
const DRY_RUN = process.argv.includes('--dry-run');
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
@@ -79,6 +79,10 @@ const MAPPINGS = [
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// Yoco payments
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
@@ -86,7 +90,7 @@ const MAPPINGS = [
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const prisma = new PrismaClient();
const prisma = createPrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Settings migration: .env → DB');
+3 -2
View File
@@ -3,10 +3,11 @@
* Run: node scripts/seed-users.js
*/
const { PrismaClient } = require('@prisma/client');
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const { createPrismaClient } = require('../src/config/db');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const prisma = createPrismaClient();
const firstNames = [
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,9 +1,10 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const generateToken = (userId, role, tokenVersion = 0) => {
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
return jwt.sign(
{ id: userId, role, tokenVersion },
{ ...extra, id: userId, role, tokenVersion },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
+14 -2
View File
@@ -1,5 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const prisma = new PrismaClient();
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000,
});
return new PrismaClient({ adapter });
}
module.exports = prisma;
const prisma = createPrismaClient();
module.exports = prisma;
module.exports.createPrismaClient = createPrismaClient;
@@ -0,0 +1,327 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const cleanName = (v, what) => {
const name = String(v || '').trim();
if (!name) throw allocationError(`Enter a name for the ${what}.`);
return name.slice(0, 100);
};
const cleanCapacity = (v) => {
if (v === undefined || v === null || v === '') return null;
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
return n;
};
/** Named attendees on the event's live registrations. */
async function eventAttendees(eventId) {
return prisma.attendee.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } } },
select: {
id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true,
registration: { select: { user: { select: { name: true } } } },
},
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
});
}
async function loadSet(id) {
const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } });
if (!set) throw allocationError('Allocation set not found', 404);
return set;
}
// @desc Everything the allocation board needs for an event
// @route GET /api/allocations/event/:eventId
// @access Private/Supervisor
const getBoard = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.eventId },
select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true },
});
if (!event) throw allocationError('Event not found', 404);
const [sets, attendees, leaders, places] = await Promise.all([
prisma.allocationSet.findMany({
where: { eventId: event.id },
include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } },
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
}),
eventAttendees(event.id),
prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }),
prisma.registrationOption.aggregate({
where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
_sum: { quantity: true },
}),
]);
res.json({
event,
sets,
attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })),
leaders,
unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length),
});
} catch (error) {
fail(res, error);
}
};
// @route POST /api/allocations/sets { eventId, name, showOnTicket? }
const createSet = async (req, res) => {
try {
const { eventId, name, showOnTicket } = req.body || {};
if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404);
const order = await prisma.allocationSet.count({ where: { eventId } });
res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } }));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/allocations/sets/:id { name?, showOnTicket? }
const updateSet = async (req, res) => {
try {
await loadSet(req.params.id);
const { name, showOnTicket } = req.body || {};
res.json(await prisma.allocationSet.update({
where: { id: req.params.id },
data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) },
}));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/sets/:id
const deleteSet = async (req, res) => {
try {
await loadSet(req.params.id);
await prisma.allocationSet.delete({ where: { id: req.params.id } });
res.json({ message: 'Deleted' });
} catch (error) {
fail(res, error);
}
};
async function groupData(body, setId, existing = null) {
const { name, capacity, gender, leaderUserId } = body || {};
const data = {};
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
if (gender !== undefined) data.gender = cleanGender(gender);
if (leaderUserId !== undefined) {
if (leaderUserId) {
const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } });
if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.');
}
data.leaderUserId = leaderUserId || null;
}
// Changing capacity or gender mustn't leave the group over capacity or mixed.
if (existing && (data.capacity !== undefined || data.gender !== undefined)) {
const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } });
const next = { ...existing, ...data };
const blocker = joinBlocker(next, 0, members.map(m => m.attendee));
if (blocker) throw allocationError(`${blocker} Move people out first.`);
}
return data;
}
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
const createGroup = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const data = await groupData(req.body, set.id);
res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } }));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/allocations/groups/:id
const updateGroup = async (req, res) => {
try {
const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } });
if (!group) throw allocationError('Group not found', 404);
const data = await groupData(req.body, group.setId, group);
res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data }));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/groups/:id (its people go back to unassigned)
const deleteGroup = async (req, res) => {
try {
await prisma.allocationGroup.delete({ where: { id: req.params.id } });
res.json({ message: 'Deleted' });
} catch (error) {
if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' });
fail(res, error);
}
};
// @desc Put an attendee in a group (groupId null = unassign)
// @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId }
const assign = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const { attendeeId, groupId } = req.body || {};
const attendee = await prisma.attendee.findFirst({
where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } },
select: { id: true, name: true, gender: true },
});
if (!attendee) throw allocationError('Attendee not found for this event', 404);
if (!groupId) {
await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } });
return res.json({ message: 'Unassigned' });
}
const group = set.groups.find(g => g.id === groupId);
if (!group) throw allocationError('Group not found in this set', 404);
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`;
const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } });
const blocker = joinBlocker(group, count, [attendee]);
if (blocker) throw allocationError(blocker);
await tx.allocationAssignment.upsert({
where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } },
update: { groupId: group.id },
create: { setId: set.id, groupId: group.id, attendeeId: attendee.id },
});
});
res.json({ message: `${attendee.name} → ${group.name}` });
} catch (error) {
fail(res, error);
}
};
// @desc Place everyone not yet in a group
// @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' }
const autoAssign = async (req, res) => {
try {
const set = await loadSet(req.params.id);
if (set.groups.length === 0) throw allocationError('Add some groups first.');
const mode = req.body?.mode === 'split' ? 'split' : 'together';
const result = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`;
const [attendees, existing] = await Promise.all([
eventAttendees(set.eventId),
tx.allocationAssignment.findMany({ where: { setId: set.id } }),
]);
const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode });
if (r.placements.length) {
await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true });
}
return r;
});
res.json({
placed: result.placements.length,
unplaced: result.unplaced.length,
message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`,
});
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/sets/:id/assignments (clear the whole set)
const clearAssignments = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } });
res.json({ message: `${count} removed from groups.` });
} catch (error) {
fail(res, error);
}
};
/** Members of groups, with contact and (for supervisors) medical details. */
async function groupMembers(groupIds, { withMedical }) {
const assignments = await prisma.allocationAssignment.findMany({
where: { groupId: { in: groupIds } },
include: {
attendee: {
select: {
id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true,
registration: { select: { user: { select: { name: true, phoneNumber: true } } } },
},
},
},
orderBy: { attendee: { name: 'asc' } },
});
let consent = new Map();
if (withMedical && assignments.length) {
const { latestRequestsFor, openJson } = require('../utils/consent');
const latest = await latestRequestsFor(assignments.map(a => a.attendeeId));
consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null]));
}
return assignments.map(a => ({
groupId: a.groupId,
id: a.attendee.id,
name: a.attendee.name,
gender: a.attendee.gender,
dateOfBirth: a.attendee.dateOfBirth,
phone: a.attendee.phone,
parentName: a.attendee.parentName,
parentPhone: a.attendee.parentPhone,
bookedBy: a.attendee.registration.user?.name || '',
bookedByPhone: a.attendee.registration.user?.phoneNumber || null,
...(withMedical ? consent.get(a.attendee.id) || {} : {}),
}));
}
// @desc Lists for printing: every group of a set, with members
// @route GET /api/allocations/sets/:id/lists?medical=true
// @access Private/Supervisor
const getSetLists = async (req, res) => {
try {
const set = await prisma.allocationSet.findUnique({
where: { id: req.params.id },
include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } },
});
if (!set) throw allocationError('Allocation set not found', 404);
const withMedical = req.query.medical === 'true';
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
require('../utils/accessLog').logDataAccess(req, {
action: withMedical ? 'medical_list' : 'group_lists', what: `${set.name} lists — ${set.event?.title || 'event'}${withMedical ? ' (with medical details)' : ''}`,
eventId: set.eventId, records: members.length,
});
if (withMedical) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
}
res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) });
} catch (error) {
fail(res, error);
}
};
// @desc Groups the signed-in staff member leads, with their people
// @route GET /api/allocations/my-groups
// @access Private/Staff
const getMyGroups = async (req, res) => {
try {
const groups = await prisma.allocationGroup.findMany({
where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } },
include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } },
orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }],
});
const withMedical = SUPERVISORS.includes(req.user.role);
const members = await groupMembers(groups.map(g => g.id), { withMedical });
if (withMedical && members.length) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) });
}
res.json({
medicalShown: withMedical,
groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })),
});
} catch (error) {
fail(res, error);
}
};
module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };
@@ -0,0 +1,44 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { track, funnelFor } = require('../utils/analytics');
// @desc Record a funnel step (event page view, registration started)
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
// @access Public — only sent by browsers whose visitor accepted cookies
const trackStage = async (req, res) => {
try {
const { sessionId, eventId, stage, source } = req.body || {};
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
} catch (e) {
console.warn('[analytics] track failed:', e?.message || e);
}
// Always a quiet 204 — tracking must never get in a visitor's way.
res.status(204).end();
};
// @desc Registration funnel for one or more events
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
// @access Private/Supervisor
const getFunnel = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
// Per event too, so events can be compared.
const perEvent = [];
if (events.length > 1) {
for (const ev of events) {
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
}
} else if (events[0]) {
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
}
res.json({ ...total, perEvent });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { trackStage, getFunnel };
@@ -0,0 +1,414 @@
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, sessionIds: 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 or ticked sessions: which sessions this person booked, and the ticket for each
// (an all-sessions ticket with sessions ticked is one ticket for all of them).
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.flatMap(tk => (tk.registrationOption.sessionId ? [[tk.registrationOption.sessionId, tk.id]] : g.sessionIds.map(id => [id, 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, sessionIds: 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 && !require('../utils/sessions').lineCovers(ticket.registrationOption, session.id)) throw badRequest('That ticket isn\'t booked for this 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);
require('../utils/accessLog').logDataAccess(req, {
action: 'attendance_export', what: `Attendance register — ${reg.event.title} (${req.query.format === 'pdf' ? 'PDF' : 'Excel'})`,
eventId: req.params.eventId, records: reg.rows.length,
});
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);
require('../utils/accessLog').logDataAccess(req, { action: 'certificates_download', what: `Certificates — ${event.title}`, eventId: event.id, records: certs.length });
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,
};
@@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) {
html: j.html ? String(j.html) : undefined,
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
template: 'custom',
filter: { status: undefined, attendeeIds: undefined },
filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) },
};
if (j.promoEventId && typeof j.promoEventId === 'string') {
payload.promoEventId = j.promoEventId;
@@ -44,6 +44,7 @@ const downloadBackup = async (req, res) => {
res.status(404);
throw new Error('Backup not found');
}
require('../utils/accessLog').logDataAccess(req, { action: 'backup_download', what: `Database backup ${filename}` });
res.download(filePath, filename);
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
+16 -11
View File
@@ -30,6 +30,7 @@ function buildEventContext(event) {
}
const { replacePlaceholders } = require('../utils/placeholders');
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
}
const recipients = Array.from(map.values());
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
// Optionally resolve event info for link/title placeholder
let event = null;
if (eventId && typeof eventId === 'string') {
event = await prisma.event.findUnique({ where: { id: eventId } });
}
const eventCtx = buildEventContext(event);
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
}
const recipients = Array.from(map.values());
// Ad-hoc broadcasts are News & announcements unless the sender marks them as an event
// update; news skips people who haven't opted in or unsubscribed.
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
if (recipients.length === 0) {
return res.status(400).json({ message: 'No valid recipients' });
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from news.' : 'No valid recipients' });
}
// Resolve event
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
const finalText = (!html ? (await replacePlaceholders(text || '', ctxBase)) + footer.text : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
}));
const sent = results.filter(r => r.status === 'fulfilled').length;
@@ -189,7 +194,7 @@ const sendBroadcast = async (req, res) => {
}
});
return res.json({ matched: recipients.length, sent });
return res.json({ kind, matched: recipients.length, skipped, sent });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
// @access Private/Supervisor or Admin
const scheduleBroadcast = async (req, res) => {
try {
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
const { scheduledAt, subject, html, text, userIds, emails, eventId, kind } = req.body || {};
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
const payload = { subject, html, text, userIds, emails, eventId };
const payload = { subject, html, text, userIds, emails, eventId, kind };
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
+325
View File
@@ -0,0 +1,325 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
};
function shape(bundle, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return {
...bundle,
items,
summary: describeBundleItems(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw new Error('Give the bundle a name');
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
async function eventOr404(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) { res.status(404); throw new Error('Event not found'); }
return event;
}
// @desc An event's bundle deals with how often each has been used
// @route GET /api/bundles/event/:eventId
// @access Private/Supervisor
const listBundles = async (req, res) => {
try {
await eventOr404(req.params.eventId, res);
const bundles = await prisma.eventBundle.findMany({
where: { eventId: req.params.eventId },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
res.json(bundles.map(b => ({
...shape(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a bundle deal
// @route POST /api/bundles/event/:eventId
// @access Private/Supervisor
const createBundle = async (req, res) => {
try {
const event = await eventOr404(req.params.eventId, res);
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.create({
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Edit a bundle deal (including switching it off). Registrations that already
// have it are only re-worked when their own items change.
// @route PUT /api/bundles/:id
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
res.json(shape(bundle));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
// registrations that got it keep their price.
// @route DELETE /api/bundles/:id
// @access Private/Supervisor
const deleteBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) {
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
} else {
await prisma.eventBundle.delete({ where: { id: existing.id } });
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
ip: getClientIp(req),
});
res.json(used > 0
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true, message: 'Bundle deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc The bundle deals a basket gets, before any coupon code
// @route POST /api/bundles/quote
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
const quoteBundles = async (req, res) => {
try {
const { eventId, options, forUserId } = req.body || {};
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
const bundles = await previewBundles(eventId, lines);
res.json({
subtotal,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
total: round2(Math.max(0, subtotal - bundles.discount)),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** The deals to show on an event's page: available bundles, without internal fields. */
async function publicBundlesForEvent(eventId) {
const bundles = await prisma.eventBundle.findMany({
where: { eventId, isActive: true },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const now = new Date();
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
+126 -36
View File
@@ -1,7 +1,20 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
const { ALL_METHODS, WRITE_OFF_CATEGORIES, PENDING_OUTCOMES, settleLine, applyPendingResolutions, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// What the cashup screen sends per method: where the money ended up and what was written off.
function cleanDraftLine(l) {
return {
method: l.method,
bankAmount: l.bankAmount ?? null,
onHandAmount: l.onHandAmount ?? null,
pendingAmount: l.pendingAmount ?? null,
pendingReference: l.pendingReference || null,
writeOffs: (Array.isArray(l.writeOffs) ? l.writeOffs : []).map(w => ({ category: w.category, amount: w.amount ?? null, reason: w.reason || null })),
notes: l.notes || null
};
}
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -66,7 +79,7 @@ const saveEventCashupDraft = async (req, res) => {
await prisma.event.update({
where: { id: eventId },
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
data: { cashupDraft: { lines: (Array.isArray(lines) ? lines : []).filter(l => l && ALL_METHODS.includes(l.method)).map(cleanDraftLine), notes: notes || null, savedAt: new Date().toISOString() } }
});
res.json({ message: 'Draft saved' });
@@ -88,40 +101,32 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
// Each method says where its expected money ended up (see settleLine). Cash is never a
// single event-wide figure: when staff counted it, the count is the cash there is, sourced
// here from the per-person counts rather than from whatever the frontend sent.
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null
};
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
const { actual: cashCounted, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
cashupLines = ALL_METHODS.map(method => {
const input = lines.find(l => l && l.method === method) || {};
const expected = financials.expectedCashByMethod[method] || 0;
const settled = settleLine({ method, expected, counted: method === 'cash' ? cashCounted : null, input });
return {
id: uuidv4(),
method,
expectedAmount: expected,
actualAmount: settled.actual,
variance: settled.variance,
notes: input.notes || null,
bankAmount: settled.bankAmount,
onHandAmount: settled.onHandAmount,
pendingAmount: settled.pendingAmount,
pendingReference: settled.pendingReference,
writeOffTotal: settled.writeOffTotal,
writeOffs: settled.writeOffs.length > 0 ? { create: settled.writeOffs } : undefined,
denominations: method === 'cash' && cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
});
}
const totalActualRevenue = isFullCashup
@@ -142,7 +147,7 @@ const closeEvent = async (req, res) => {
performedById: req.user.id,
lines: { create: cashupLines }
},
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
include: { lines: { include: { denominations: true, writeOffs: true } }, performedBy: { select: { id: true, name: true, email: true } } }
});
await prisma.event.update({
@@ -185,9 +190,33 @@ const reopenEvent = async (req, res) => {
include: { performedBy: { select: { id: true, name: true, email: true } } }
});
// Start the next cashup from the last one, with pending money that has since been
// received or written off folded in, so nothing has to be typed in again.
const lastClose = await prisma.eventCashup.findFirst({
where: { eventId, action: { in: ['closed', 'quick_closed'] } },
orderBy: { createdAt: 'desc' },
include: { lines: { include: { writeOffs: true, pendingResolutions: true } } }
});
const cashupDraft = lastClose && lastClose.lines.length > 0 ? {
lines: lastClose.lines.map(l => {
const settled = applyPendingResolutions(l);
return cleanDraftLine({
method: l.method,
bankAmount: settled.bank,
onHandAmount: settled.onHand,
pendingAmount: settled.pendingOutstanding || null,
pendingReference: settled.pendingOutstanding ? settled.pendingReference : null,
writeOffs: settled.writeOffs,
notes: l.notes
});
}),
notes: null,
savedAt: new Date().toISOString()
} : undefined;
await prisma.event.update({
where: { id: eventId },
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id, ...(cashupDraft ? { cashupDraft } : {}) }
});
res.status(201).json(auditRow);
@@ -196,6 +225,66 @@ const reopenEvent = async (req, res) => {
}
};
// @desc Settle money that was pending when the event was closed (on the card report, or a
// POP we hadn't seen in the bank yet): it has now reached the bank, or it never will and
// is written off. Allowed while the event is closed — no reopen needed.
// @route POST /api/cashups/lines/:lineId/pending
// @access Private/Admin
const resolvePending = async (req, res) => {
try {
const { lineId } = req.params;
const { outcome, amount, category, notes } = req.body;
if (!PENDING_OUTCOMES.includes(outcome)) {
res.status(400);
throw new Error('Say whether the money was received or written off');
}
const value = Math.round(Number(amount) * 100) / 100;
if (!Number.isFinite(value) || value <= 0) {
res.status(400);
throw new Error('Enter an amount more than R0');
}
const resolution = await prisma.$transaction(async (tx) => {
// Lock the line so two people settling at once can't settle more than is pending.
await tx.$executeRaw`SELECT 1 FROM "EventCashupLine" WHERE id = ${lineId} FOR UPDATE`;
const line = await tx.eventCashupLine.findUnique({
where: { id: lineId },
include: { cashup: { select: { id: true, eventId: true } }, pendingResolutions: true }
});
if (!line) {
res.status(404);
throw new Error('Cashup line not found');
}
const latest = await tx.eventCashup.findFirst({ where: { eventId: line.cashup.eventId }, orderBy: { createdAt: 'desc' }, select: { id: true } });
if (!latest || latest.id !== line.cashupId) {
res.status(400);
throw new Error('Only the current cashup of a closed event can be updated');
}
const { pendingOutstanding } = applyPendingResolutions(line);
if (value > pendingOutstanding + 0.005) {
res.status(400);
throw new Error(`Only R${pendingOutstanding.toFixed(2)} is still pending`);
}
return tx.eventCashupPendingResolution.create({
data: {
lineId,
outcome,
amount: value,
category: outcome === 'written_off' ? (WRITE_OFF_CATEGORIES.includes(category) ? category : 'other') : null,
notes: notes ? String(notes).trim().slice(0, 200) : null,
performedById: req.user.id
},
include: { performedBy: { select: { id: true, name: true } } }
});
});
res.status(201).json(resolution);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
// @route GET /api/cashups/audit
// @access Private/Supervisor
@@ -234,5 +323,6 @@ module.exports = {
saveEventCashupDraft,
closeEvent,
reopenEvent,
resolvePending,
getCashupAudit
};
@@ -0,0 +1,266 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { describeUserAgent } = require('../utils/userAgent');
const {
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
findRequestByToken, resendConsentRequest, createConsentRequest,
} = require('../utils/consent');
const STAFF = ['admin', 'supervisor', 'staff'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
// What the parent sees — never the registrant's contact details or other attendees.
function publicView(request) {
const ev = request.event;
const latestForAttendee = request._isLatest !== false;
return {
status: request.status,
superseded: !latestForAttendee,
expired: new Date(request.expiresAt) < new Date(),
respondedAt: request.respondedAt,
attendeeName: request.attendee.name,
parentName: request.parentName,
consentText: request.consentText,
textVersion: request.textVersion,
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
};
}
// @desc Consent page data for a parent/guardian
// @route GET /api/consent/:token
// @access Public (the token is the credential)
const getConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
} catch (error) {
fail(res, error);
}
};
// @desc Give or decline consent
// @route POST /api/consent/:token
// @access Public (the token is the credential)
const answerConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
let answer;
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
// Only flips pending → answered once, even if the form is submitted twice at once.
const updated = await prisma.consentRequest.updateMany({
where: { id: request.id, status: 'pending' },
data: {
status: answer.decision === 'give' ? 'given' : 'declined',
signedName: answer.signedName,
ip: getClientIp(req),
device: describeUserAgent(req.headers['user-agent']),
medical: sealJson(answer.medical),
emergency: sealJson(answer.emergency),
photoConsent: answer.photoConsent,
respondedAt: new Date(),
},
});
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
} catch (error) {
fail(res, error);
}
};
// Tells the registrant and the event's notify recipients how a parent answered.
async function notifyConsentAnswered(requestId) {
const request = await prisma.consentRequest.findUnique({
where: { id: requestId },
include: {
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
if (!request) return;
const { sendMail, emailWrapper } = require('../utils/email');
const { shouldEmail, waText } = require('../utils/notify');
const { buildEventNotifyRecipientList } = require('../utils/notifications');
const given = request.status === 'given';
const verb = given ? 'given' : 'declined';
const who = request.attendee.name;
const ev = request.event.title;
const payer = request.attendee.registration.user;
const line = given
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
const sends = [];
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
if (payer) sends.push(waText(payer, line));
const staff = buildEventNotifyRecipientList(request.event);
if (staff.length) {
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
await Promise.allSettled(sends);
}
/** One row per attendee on the event, with consent state — for the consent tracker. */
async function consentRows(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return { event: null, rows: [] };
const attendees = await prisma.attendee.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } } },
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
});
const latest = await latestRequestsFor(attendees.map(a => a.id));
const rows = attendees.map(a => {
const r = latest.get(a.id) || null;
return {
attendeeId: a.id,
name: a.name,
dateOfBirth: a.dateOfBirth,
minor: isMinor(a.dateOfBirth, event.startDate),
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
registrationId: a.registrationId,
registrationStatus: a.registration.status,
bookedBy: a.registration.user,
status: consentStatusFor(a, event, r),
request: r && {
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
},
};
});
return { event, rows };
}
// @desc Consent status for every attendee on an event
// @route GET /api/consent/events/:eventId
// @access Private/Staff
const listEventConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
require('../utils/accessLog').logDataAccess(req, { action: 'consent_list', what: `Parental consent — ${event.title}`, eventId: event.id, records: rows.length });
res.json({
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
counts,
rows,
});
} catch (error) {
fail(res, error);
}
};
// @desc Re-send one consent request (with a fresh link)
// @route POST /api/consent/requests/:id/resend
// @access Private (the registrant, or staff)
const resendRequest = async (req, res) => {
try {
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
if (!request) { res.status(404); throw new Error('Consent request not found'); }
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
const sent = await resendConsentRequest(request.id);
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
} catch (error) {
fail(res, error);
}
};
// @desc Re-send every consent request still pending on an event
// @route POST /api/consent/events/:eventId/resend-pending
// @access Private/Supervisor
const resendPending = async (req, res) => {
try {
const { rows } = await consentRows(req.params.eventId);
const pending = rows.filter(r => r.status === 'pending' && r.request);
let sent = 0;
for (const r of pending) {
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
}
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
} catch (error) {
fail(res, error);
}
};
// @desc Ask again for consent where it was given on an older version of the text,
// and for minors who never got a request (e.g. names added later)
// @route POST /api/consent/events/:eventId/rerequest
// @access Private/Supervisor
const rerequestConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
let created = 0;
for (const r of targets) {
if (!r.parentEmail && !r.parentPhone) continue;
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
await createConsentRequest(attendee, event);
created += 1;
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
ip: getClientIp(req),
});
res.json({ message: `Sent ${created} new consent request(s).`, created });
} catch (error) {
fail(res, error);
}
};
// @desc Medical & emergency list for an event (decrypted) — every view is audited
// @route GET /api/consent/events/:eventId/medical
// @access Private/Supervisor
const medicalList = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
const { rows } = await consentRows(event.id);
const ids = rows.map(r => r.request?.id).filter(Boolean);
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
const byId = new Map(full.map(r => [r.id, r]));
const list = rows
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
.map(r => {
const fr = byId.get(r.request.id);
return {
name: r.name, dateOfBirth: r.dateOfBirth,
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
purged: !!fr.medicalPurgedAt,
};
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
ip: getClientIp(req),
});
require('../utils/accessLog').logDataAccess(req, { action: 'medical_list', what: `Medical list — ${event.title}`, eventId: event.id, records: list.length });
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
} catch (error) {
fail(res, error);
}
};
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
+375
View File
@@ -0,0 +1,375 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/**
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
async function basketBuyer(req, forUserId) {
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
return { buyer, isStaff };
}
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
// sessions ticked is still one line at one price).
const { isPickOption } = require('../utils/sessions');
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? 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;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
return buildCouponLines({ registrationOptions });
}
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, eventId and options are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer, isStaff } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount, bundles } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
subtotal,
total: Math.max(0, subtotal - bundles.discount - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Coupon and bundle-deal uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
res.json([
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id,
kind: 'bundle',
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
]);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
+315 -56
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) {
@@ -44,7 +123,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -62,11 +141,33 @@ const createEvent = async (req, res) => {
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
requiresParentalConsent: requiresParentalConsent === true,
consentText: consentText ? String(consentText) : null,
consentCollectMedical: consentCollectMedical === true,
consentCollectEmergency: consentCollectEmergency === true,
consentCollectPhoto: consentCollectPhoto === true,
waitlistEnabled: waitlistEnabled === true,
allowTicketTransfer: allowTicketTransfer !== false,
collectGender: collectGender === true,
allowSponsorships: allowSponsorships === true,
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: waitlistPartialOffers !== false,
contactName: contactName || null,
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,
@@ -81,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.
@@ -136,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 || '');
@@ -171,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
@@ -192,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)
@@ -205,24 +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 soldCounts = await Promise.all(limitedOpts.map(opt =>
prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true },
}).then(r => r._sum?.quantity || 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 });
}
}));
@@ -240,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),
}));
@@ -281,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),
}));
@@ -309,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.
@@ -418,22 +561,25 @@ const getEventById = async (req, res) => {
try {
if (canIncludeVariants) {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Total sold quantity for this option (all non-cancelled)
const soldAgg = await prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const soldCount = soldAgg._sum?.quantity || 0;
// 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;
// Per-variant stock
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
const vAgg = await prisma.registrationOption.aggregate({
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const vSold = vAgg._sum?.quantity || 0;
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
}));
@@ -444,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),
@@ -453,7 +601,8 @@ const getEventById = async (req, res) => {
url: toAbsoluteUrl(req, att.url)
})),
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 });
@@ -468,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) {
@@ -537,7 +686,7 @@ const updateEvent = async (req, res) => {
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -552,16 +701,62 @@ const updateEvent = async (req, res) => {
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
? true
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
// Editing the consent text starts a new version; parents who already agreed keep
// their record of the old one (supervisors can re-request consent).
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
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
@@ -718,6 +913,15 @@ const deleteEvent = async (req, res) => {
}
};
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
function parseStaffPrice(value, current = null) {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(value);
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
return n;
}
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
@@ -734,7 +938,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -744,8 +948,11 @@ const createEventOption = async (req, res) => {
price: parseFloat(price),
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) {
@@ -770,7 +977,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -779,6 +986,8 @@ const updateEventOption = async (req, res) => {
price: price !== undefined ? parseFloat(price) : eventOption.price,
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,
}
});
@@ -847,10 +1056,11 @@ const updateEventOption = async (req, res) => {
if (!v.name) continue;
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
const variantStaffPrice = parseStaffPrice(v.staffPrice);
if (v.id) {
await prisma.optionVariant.update({
where: { id: v.id },
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
});
} else {
await prisma.optionVariant.create({
@@ -860,6 +1070,7 @@ const updateEventOption = async (req, res) => {
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
@@ -867,6 +1078,10 @@ const updateEventOption = async (req, res) => {
}
}
if (stockLimit !== undefined || variants !== undefined) {
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
}
const refreshed = await prisma.eventOption.findUnique({
where: { id: updatedEventOption.id },
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
@@ -937,7 +1152,7 @@ const createOptionVariant = async (req, res) => {
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
await assertEventOpen(eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
if (!name) { res.status(400); throw new Error('Variant name is required'); }
const variant = await prisma.optionVariant.create({
data: {
@@ -946,6 +1161,7 @@ const createOptionVariant = async (req, res) => {
name,
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
order: order !== undefined ? parseInt(order, 10) : 0,
}
});
@@ -963,11 +1179,12 @@ const updateOptionVariant = async (req, res) => {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
const updated = await prisma.optionVariant.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : variant.name,
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
order: order !== undefined ? parseInt(order, 10) : variant.order,
@@ -1296,6 +1513,11 @@ const emailEventAttendees = async (req, res) => {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
// Optional explicit attendee selection by userIds
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
@@ -1346,8 +1568,34 @@ const emailEventAttendees = async (req, res) => {
}
const recipients = Array.from(uniqMap.values());
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
if (filter?.audience === 'sponsors') {
recipients.length = 0;
registrations.length = 0;
const sponsorships = await prisma.payment.findMany({
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
include: { user: { select: { name: true, email: true, isActive: true } } },
orderBy: { createdAt: 'asc' },
});
const seen = new Set();
for (const s of sponsorships) {
const email = (s.user?.email || '').trim();
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
seen.add(email);
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
}
}
// POPIA: an "update" (about this event) goes to everyone selected; "news" (the next-event
// promo, or when the sender says so) only to people who opted in. Tickets are always sent.
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
const kind = template === 'tickets' ? 'update' : (promoEvent ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
const allowed = await filterRecipients(recipients, { channel: 'email', kind });
recipients.length = 0;
recipients.push(...allowed.recipients);
if (dryRun) {
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
}
const { sendMail } = require('../utils/email');
@@ -1373,7 +1621,7 @@ const emailEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Generates a live Yoco checkout link for the recipient's first registration with an
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
@@ -1384,8 +1632,8 @@ const emailEventAttendees = async (req, res) => {
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
@@ -1490,10 +1738,11 @@ const emailEventAttendees = async (req, res) => {
// Always perform placeholder replacement on whatever we have
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
const finalHtml = h ? (await replacePlaceholders(h, ctxHtml)) + footer.html : undefined;
const finalText = (!h ? (await replacePlaceholders(t || '', ctxBase)) + footer.text : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
@@ -1534,6 +1783,11 @@ const whatsappEventAttendees = async (req, res) => {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
where.userId = { in: attendeeIds };
@@ -1579,10 +1833,15 @@ const whatsappEventAttendees = async (req, res) => {
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
}
}
const recipients = Array.from(uniqMap.values());
// POPIA: see emailEventAttendees — news only to people who opted in to WhatsApp news.
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
const promoEventId = req.body?.promoEventId;
const kind = template === 'tickets' ? 'update' : (promoEventId ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
const allowed = await filterRecipients(Array.from(uniqMap.values()), { channel: 'whatsapp', kind });
const recipients = allowed.recipients;
if (dryRun) {
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
}
const { sendText } = require('../utils/whatsapp');
@@ -1610,7 +1869,7 @@ const whatsappEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
// Generates a live Yoco checkout link for the recipient's first registration with an
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
@@ -1620,8 +1879,8 @@ const whatsappEventAttendees = async (req, res) => {
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
@@ -1682,7 +1941,7 @@ const whatsappEventAttendees = async (req, res) => {
}
const finalMessage = await replacePlaceholders(msg || '', ctx);
await sendText(rcpt.phone, finalMessage);
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
@@ -1710,7 +1969,7 @@ function describeAttendeeFilter(filter) {
const scheduleEmailEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { scheduledAt, subject, html, text, filter, template } = req.body || {};
const { scheduledAt, subject, html, text, filter, template, kind } = req.body || {};
// Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } });
@@ -1728,7 +1987,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
}
// Build payload matching emailEventAttendees body
const payload = { subject, html, text, filter: filter || {}, template };
const payload = { subject, html, text, filter: filter || {}, template, kind };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
@@ -1751,7 +2010,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
const scheduleWhatsappEventAttendees = async (req, res) => {
try {
const eventId = req.params.id;
const { scheduledAt, message, filter, template } = req.body || {};
const { scheduledAt, message, filter, template, kind } = req.body || {};
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
@@ -1765,7 +2024,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
throw new Error('message is required unless template is "tickets"');
}
const payload = { message, filter: filter || {}, template };
const payload = { message, filter: filter || {}, template, kind };
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
@@ -0,0 +1,179 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
async function formWithStats(eventId) {
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!form) return null;
const [invited, responses] = await Promise.all([
prisma.feedbackInvite.count({ where: { formId: form.id } }),
prisma.feedbackResponse.count({ where: { formId: form.id } }),
]);
return { ...form, invited, responses };
}
// @desc An event's feedback form (with invite/response counts)
// @route GET /api/feedback/event/:eventId
// @access Private/Supervisor
const getEventFeedback = async (req, res) => {
try {
res.json(await formWithStats(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @desc Create or update an event's feedback form
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
// @access Private/Supervisor
const saveEventFeedback = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
if (!event) throw feedbackError('Event not found', 404);
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
const clean = cleanFields(fields);
let when = null;
if (sendAt) {
when = new Date(sendAt);
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
}
const data = {
sendAt: when,
audience: audience === 'all' ? 'all' : 'checked_in',
anonymous: anonymous === true,
intro: intro ? String(intro).slice(0, 2000) : null,
};
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!existing) {
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
} else {
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
const changed = shape(existing.fields) !== shape(clean);
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
await prisma.$transaction(async (tx) => {
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
if (changed) {
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
}
});
}
res.json(await formWithStats(event.id));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/feedback/event/:eventId
const deleteEventFeedback = async (req, res) => {
try {
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
res.json({ message: 'Feedback form removed' });
} catch (error) {
fail(res, error);
}
};
// @desc Send the feedback form now (instead of waiting for its send time)
// @route POST /api/feedback/event/:eventId/send-now
// @access Private/Supervisor
const sendNow = async (req, res) => {
try {
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
if (!form) throw feedbackError('This event has no feedback form.', 404);
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
// Also invites anyone who checked in after the first send.
const invited = await sendFeedbackInvites(form.id);
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
} catch (error) {
fail(res, error);
}
};
// @desc The form behind a personal feedback link
// @route GET /api/feedback/public/:token
// @access Public (link holder)
const getPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
const { form } = invite;
res.json({
event: form.event,
name: invite.user?.name || '',
intro: form.intro,
anonymous: form.anonymous,
responded: !!invite.respondedAt,
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
});
} catch (error) {
fail(res, error);
}
};
// @desc Submit feedback (once per person)
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
// @access Public (link holder)
const submitPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
const answers = req.body?.answers || {};
const rows = [];
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
const value = cleanAnswer(f, answers[f.id]);
if (value !== null) rows.push({ fieldId: f.id, value });
}
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
await prisma.$transaction(async (tx) => {
// Claim the invite first, so a double-submit can't create two responses.
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
await tx.feedbackResponse.create({
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
});
});
res.status(201).json({ message: 'Thank you for your feedback!' });
} catch (error) {
fail(res, error);
}
};
// @desc Feedback summary for one or more events (for the report, and comparisons)
// @route GET /api/feedback/summary?eventIds=a,b
// @access Private/Supervisor
const getSummary = async (req, res) => {
try {
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
const forms = await prisma.feedbackForm.findMany({
where: { eventId: { in: ids } },
include: {
event: { select: { id: true, title: true, startDate: true } },
fields: { orderBy: { order: 'asc' } },
responses: { include: { answers: true, user: { select: { name: true } } } },
_count: { select: { invites: true } },
},
});
const out = forms.map(form => {
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
const invited = form._count.invites;
return {
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
anonymous: form.anonymous, sentAt: form.sentAt,
invited, responses: form.responses.length,
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
questions: summarise(form.fields, answers),
};
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
res.json(out);
} catch (error) {
fail(res, error);
}
};
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };
+11
View File
@@ -43,6 +43,17 @@ const listFormResponses = async (req, res) => {
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
// The first page of a list (not one registration's own form, nor the next pages of the
// same list) is logged as a bulk view of form answers.
if (!registrationId && !cursor && responses.length) {
require('../utils/accessLog').logDataAccess(req, {
action: 'form_responses',
what: `Attendee form answers${responses[0].registration?.event && eventId ? ` — ${responses[0].registration.event.title}` : ''}`,
eventId: eventId ? String(eventId) : null,
records: responses.length,
});
}
res.json({ items: responses, nextCursor });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -0,0 +1,109 @@
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
// @desc Start viewing the site as a user (read-only, 30 minutes)
// @route POST /api/admin/impersonate/:userId { reason }
// @access Private/Admin (with 2FA)
const startImpersonation = async (req, res) => {
try {
if (req.readOnly) throw err('End the current impersonation first.', 403);
const reason = String(req.body?.reason || '').trim();
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
const blocker = impersonationBlocker(req.user, target);
if (blocker) throw err(blocker, target ? 403 : 404);
// One live session per admin.
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
});
require('../utils/accessLog').logDataAccess(req, { action: 'impersonation', what: `Viewed as ${target.name} — ${session.reason}` });
// Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache');
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
const { sendMail } = require('../utils/email');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
sendMail({
to: target.email,
subject: `An administrator viewed your account — ${orgName}`,
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
}).catch(() => {});
}
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
} catch (error) {
fail(res, error);
}
};
// @desc End impersonation (called with the impersonation token)
// @route POST /api/admin/impersonate/end
// @access Impersonation token
const endImpersonation = async (req, res) => {
try {
const sessionId = req.impersonation?.id;
if (!sessionId) throw err('Not impersonating.', 400);
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
logAdminAction({
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
});
res.json({ message: 'Impersonation ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Record a page the admin viewed while impersonating
// @route POST /api/admin/impersonate/log { path }
// @access Impersonation token
const logPageView = async (req, res) => {
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
res.status(204).end();
};
// @desc The current impersonation session (for the banner)
// @route GET /api/admin/impersonate/current
// @access Impersonation token
const currentImpersonation = async (req, res) => {
const s = req.impersonation;
if (!s) return res.json(null);
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
};
// @desc Impersonation sessions with their request trail (audit log page)
// @route GET /api/admin/impersonation-sessions
// @access Private/Admin
const listSessions = async (req, res) => {
try {
const sessions = await prisma.impersonationSession.findMany({
orderBy: { startedAt: 'desc' },
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
include: {
admin: { select: { name: true, email: true } },
target: { select: { name: true, email: true } },
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
},
});
res.json(sessions);
} catch (error) {
fail(res, error);
}
};
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
+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 };
@@ -0,0 +1,97 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { maskSecret } = require('../utils/yoco');
const {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastNotifyUrl, notifyUrlProblem,
} = require('../utils/payfast');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// @desc PayFast settings (passphrase masked) and the ITN notify URL
// @route GET /api/payfast/config
// @access Admin
const getConfig = async (req, res) => {
try {
const c = await getPayfastConfig();
const notifyUrl = payfastNotifyUrl();
res.json({
merchantId: c.merchantId,
merchantKey: c.merchantKey,
passphraseMasked: c.passphrase ? maskSecret(c.passphrase) : '',
hasPassphrase: !!c.passphrase,
mode: c.mode,
notifyUrl,
notifyUrlProblem: notifyUrlProblem(notifyUrl),
configured: !!(c.merchantId && c.merchantKey),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save PayFast settings ('' clears the passphrase; omitted fields stay as they are)
// @route PUT /api/payfast/config { merchantId?, merchantKey?, passphrase?, mode? }
// @access Admin
const updateConfig = async (req, res) => {
try {
const { merchantId, merchantKey, passphrase, mode } = req.body || {};
const updates = {};
if (typeof merchantId === 'string') {
const v = merchantId.trim();
if (v && !/^\d{5,12}$/.test(v)) { res.status(400); throw new Error('The merchant ID is a number, e.g. 10000100.'); }
updates[KEY_MERCHANT_ID] = v;
}
if (typeof merchantKey === 'string') {
const v = merchantKey.trim();
if (v && !/^[A-Za-z0-9]{8,32}$/.test(v)) { res.status(400); throw new Error('The merchant key is letters and numbers, as shown on your PayFast dashboard.'); }
updates[KEY_MERCHANT_KEY] = v;
}
if (typeof passphrase === 'string') {
// PayFast passphrases are 32 characters at most; spaces at the ends are trimmed by PayFast.
if (passphrase.trim().length > 32) { res.status(400); throw new Error('The passphrase can be at most 32 characters.'); }
updates[KEY_PASSPHRASE] = passphrase.trim();
}
if (mode !== undefined) {
if (!['sandbox', 'live'].includes(mode)) { res.status(400); throw new Error('Mode is sandbox or live.'); }
updates[KEY_MODE] = mode;
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await prisma.$transaction(Object.entries(updates).map(([key, value]) => {
const stored = value && key === KEY_PASSPHRASE ? encrypt(value) : value;
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
}));
invalidateSettingsCache();
await warmCache();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'payfast_config_updated', targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates), ...(updates[KEY_MODE] ? { mode: updates[KEY_MODE] } : {}) },
ip: getClientIp(req),
});
res.json({ message: 'PayFast settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping)
// @route POST /api/payfast/test
// @access Admin
const testConnection = async (req, res) => {
try {
const c = await getPayfastConfig();
if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); }
await require('../payments/providers/payfast').testConnection();
res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, testConnection };
+69 -222
View File
@@ -5,7 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const payments = require('../payments');
const { createRegistrationCheckout } = require('../payments/checkouts');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -913,112 +914,23 @@ const unassignDonationFromRegistration = async (req, res) => {
}
};
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
user: { select: { id: true, name: true, email: true } },
event: true
}
});
if (!registration) throw new Error('Registration not found');
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
const amountInCents = Math.round(remainingAmount * 100);
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
// Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: {
price: Math.round(unitPrice * 100)
}
};
});
// Show prior payments as a deduction line
if (totalPaid > 0) {
lineItems.push({
displayName: 'Previous Payments',
quantity: 1,
pricingDetails: { price: -Math.round(totalPaid * 100) }
});
}
// Paying full remaining — no "Partial Payment" line needed
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
successUrl,
cancelUrl,
failureUrl,
lineItems
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
where: { id: registrationId },
data: { checkoutId: response.data.id, updatedAt: new Date() }
});
return {
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: remainingAmount,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
};
}
// @desc Create a Yoco checkout for a registration
// @route POST /api/payments/yoco-checkout
// @desc Start an online checkout: a registration's balance (all of it, or part of it, plus
// an optional donation) or a donation to an event. `provider` picks the payment
// provider; by default the first one enabled in Site settings → Payments.
// @route POST /api/payments/checkout (also /api/payments/yoco-checkout)
// @access Private
const createYocoCheckout = async (req, res) => {
const createCheckout = async (req, res) => {
try {
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl, provider } = req.body;
const userId = req.user.id;
// Validate required parameters for either registration payment or donation
if (!registrationId && !eventId) {
res.status(400);
throw new Error('Either registrationId or eventId is required');
}
// Branch 1: Registration payment — delegate to internal helper
// A registration's balance
if (registrationId) {
// Authorisation check (the internal helper skips this)
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
@@ -1031,10 +943,9 @@ const createYocoCheckout = async (req, res) => {
await assertEventOpen(reg.eventId, res);
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
// Must run before totalDue is computed so the correct price is used for the checkout amount.
// Must run before the balance is worked out so the checkout charges the right price.
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
if (priceChanged) {
// Re-fetch with fresh priceSnapshot values
const freshReg = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
@@ -1051,138 +962,75 @@ const createYocoCheckout = async (req, res) => {
});
}
// If a specific partial amount was passed, fall through to the old inline path
// Part of the balance (supervisor payments page)
if (amount && !isNaN(parseFloat(amount))) {
// Re-use old inline logic for partial payments
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
user: { select: { id: true, name: true, email: true } },
event: true
}
});
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); }
const requested = parseFloat(amount);
if (requested <= 0) { res.status(400); throw new Error('Amount must be greater than 0'); }
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
const charge = Math.min(requested, remainingAmount);
const amountInCents = Math.round(charge * 100);
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: { price: Math.round(unitPrice * 100) }
};
});
// Previous payments deduction
if (totalPaid > 0) {
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
}
// Partial payment deduction — amount being deferred to a future payment
if (charge < remainingAmount) {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
return res.status(200).json(result);
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
// Optional donation added at registration ("Donation amount" on the register form)
let donation = 0;
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
}
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
return res.status(200).json(result);
}
// Branch 2: Donation to an event
if (eventId) {
// Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < 15) {
res.status(400);
throw new Error('Minimum donation is R15');
}
const amountInCents = Math.round(amt * 100);
const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`;
const metadata = {
userId,
eventId,
eventTitle: event.title,
type: 'donation',
isDonation: true
};
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata,
successUrl,
cancelUrl,
failureUrl,
lineItems: [
{ displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } }
]
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: amt,
event: { id: event.id, title: event.title }
});
// A donation to an event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.response?.status || 400).json({
message: error.response?.data?.message || error.message
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < payments.MIN_ONLINE_AMOUNT) {
res.status(400);
throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
}
const amountInCents = Math.round(amt * 100);
const checkout = await payments.createCheckout(provider, {
amountCents: amountInCents,
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
description: `Donation to ${event.title}`,
payer: { name: req.user.name || '', email: req.user.email || '' },
successUrl, cancelUrl, failureUrl,
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
});
return res.status(200).json({
checkoutId: checkout.checkoutId,
redirectUrl: checkout.redirectUrl,
provider: checkout.provider,
amount: amt,
event: { id: event.id, title: event.title }
});
} catch (error) {
console.error('Checkout error:', error.message);
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp
// @route POST /api/payments/yoco-checkout/send
// @desc Deliver a previously-generated payment link to the registrant via email or WhatsApp
// @route POST /api/payments/checkout/send (also /api/payments/yoco-checkout/send)
// @access Private/Supervisor
const sendPaymentLink = async (req, res) => {
try {
@@ -1471,8 +1319,7 @@ module.exports = {
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
createCheckout,
sendPaymentLink,
createRefund,
getPaymentStats,
@@ -0,0 +1,177 @@
const prisma = require('../config/db');
const payments = require('../payments');
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
const { safeErrorMessage } = require('../utils/errorUtils');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// @desc Payment providers people can pay with now (enabled and set up), in display order
// @route GET /api/payments/providers
// @access Public
const listPaymentProviders = async (req, res) => {
try {
const providers = await payments.availableProviders();
res.json({ providers: providers.map(p => ({ id: p.id, label: p.label })), minimumAmount: payments.MIN_ONLINE_AMOUNT });
} catch (e) {
fail(res, e);
}
};
// @desc Every provider with its Enabled flag, order and whether it's set up
// @route GET /api/payments/providers/settings
// @access Admin
const getPaymentProviderSettings = async (req, res) => {
try {
res.json({ providers: await payments.providerSettings(), minimumAmount: payments.MIN_ONLINE_AMOUNT });
} catch (e) {
fail(res, e);
}
};
// @desc Turn providers on/off and set their order
// @route PUT /api/payments/providers/settings { providers: [{ id, enabled }] } (in display order)
// @access Admin
const updatePaymentProviderSettings = async (req, res) => {
try {
const list = req.body?.providers;
if (!Array.isArray(list) || list.length === 0) { res.status(400); throw new Error('providers must be a list'); }
const unknown = list.find(p => !payments.getProvider(p?.id));
if (unknown) { res.status(400); throw new Error(`Unknown payment provider: ${unknown?.id}`); }
const before = await payments.providerSettings();
const value = payments.serializeProviderSettings(list);
await prisma.appSetting.upsert({ where: { key: payments.SETTINGS_KEY }, update: { value }, create: { key: payments.SETTINGS_KEY, value } });
invalidateSettingsCache();
await warmCache();
const after = await payments.providerSettings();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'payment_providers_updated', targetType: 'AppSetting',
metadata: { before: before.map(p => ({ id: p.id, enabled: p.enabled })), after: after.map(p => ({ id: p.id, enabled: p.enabled })) },
ip: getClientIp(req),
});
res.json({ message: 'Payment providers saved', providers: after });
} catch (e) {
fail(res, e);
}
};
/** A checkout and whether it has been paid (a payment came back for it, or its registration is paid). */
async function loadCheckout(id) {
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
if (!checkout) return null;
return { checkout, paid: await checkoutPaid(checkout) };
}
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
// @desc A checkout for the payer's page (/pay/:id): amount, what it's for, the providers
// they can pay with, and — once a form provider (PayFast) is chosen — its signed form
// @route GET /api/payments/pay/:id
// @access Public (the checkout id is the credential)
const getPayPage = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
const { checkout, paid } = found;
const request = checkout.request || {};
const out = {
id: checkout.id,
status: paid ? 'paid' : 'open',
amount: checkout.amountCents / 100,
description: request.description || '',
provider: checkout.provider,
providers: providerList(await payments.availableProviders()),
cancelUrl: request.cancelUrl || null,
redirectUrl: null,
form: null,
};
if (!paid && checkout.provider) {
const provider = payments.getProvider(checkout.provider);
if (checkout.redirectUrl) out.redirectUrl = checkout.redirectUrl;
else if (provider) out.form = (await payments.startCheckout(checkout, provider)).form || null;
}
res.json(out);
} catch (e) {
fail(res, e);
}
};
// @desc Start a checkout with the provider the payer picked
// @route POST /api/payments/pay/:id { provider }
// @access Public (the checkout id is the credential)
const choosePayProvider = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
if (found.paid) { res.status(409); throw new Error('This has already been paid.'); }
const provider = payments.getProvider(req.body?.provider);
if (!provider) { res.status(400); throw new Error('Choose how you\'d like to pay.'); }
const usable = (await payments.availableProviders()).some(p => p.id === provider.id);
if (!usable) { res.status(400); throw new Error(`Paying with ${provider.label} isn't available right now.`); }
const started = await payments.startCheckout(found.checkout, provider);
res.json({ provider: provider.id, redirectUrl: started.form ? null : started.redirectUrl, form: started.form || null });
} catch (e) {
fail(res, e);
}
};
const OPEN_DAYS = 14;
/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */
async function describeCheckout(c, users) {
const meta = c.metadata || {};
const provider = payments.getProvider(c.provider);
return {
id: c.id,
provider: c.provider,
providerLabel: provider?.label || c.provider,
canCheck: !!provider?.findPayments,
amount: c.amountCents / 100,
description: c.request?.description || meta.eventTitle || '',
type: meta.type || null,
registrationId: meta.registrationId || null,
payer: users.get(meta.userId) || c.request?.payer?.name || null,
createdAt: c.createdAt,
};
}
// @desc Online checkouts started in the last 14 days that have no payment yet
// @route GET /api/payments/checkouts/open
// @access Supervisor
const listOpenCheckouts = async (req, res) => {
try {
const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))];
const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name]));
res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS });
} catch (e) {
fail(res, e);
}
};
// @desc Ask the providers whether open checkouts were paid, and record what they find
// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one)
// @access Supervisor
const checkCheckoutStatus = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null;
const list = ids
? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } })
: await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
const results = await checkCheckouts(list);
const out = Object.fromEntries(results);
const recorded = [...results.values()].filter(r => r.status === 'recorded').length;
res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' });
} catch (e) {
fail(res, e);
}
};
module.exports = {
listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider,
listOpenCheckouts, checkCheckoutStatus,
};
@@ -0,0 +1,447 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { ACCESS_ACTIONS } = require('../utils/accessLog');
const notice = require('../utils/privacyNotice');
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
const PAGE_SIZE = 50;
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
// ─── Privacy notice ───────────────────────────────────────────────────────────
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
// @route GET /api/privacy/notice
// @access Public
const getNotice = async (req, res) => {
try {
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
const result = await notice.publicNotice(version);
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
res.json(result);
} catch (error) {
fail(res, error);
}
};
// @desc Accept the current privacy notice
// @route POST /api/privacy/me/notice/accept
// @access Private
const acceptMyNotice = async (req, res) => {
try {
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
res.json({ version });
} catch (error) {
fail(res, error);
}
};
async function versionList() {
const [versions, counts] = await Promise.all([
prisma.privacyNotice.findMany({
orderBy: { version: 'desc' },
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
}),
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
]);
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
}
// @desc The editor: the draft (or current text), how it reads, and the published versions
// @route GET /api/privacy/notice/draft
// @access Private/Admin
const getNoticeDraft = async (req, res) => {
try {
const draft = await notice.getDraft();
const ctx = await notice.noticeContext();
res.json({
...draft,
rendered: notice.renderSections(draft.sections, ctx),
placeholders: notice.PLACEHOLDERS,
versions: await versionList(),
builtIn: notice.defaultSections(),
});
} catch (error) {
fail(res, error);
}
};
// @desc Save the draft (body.sections)
// @route PUT /api/privacy/notice/draft
// @access Private/Admin
const saveNoticeDraft = async (req, res) => {
try {
const sections = await notice.saveDraft(req.body?.sections);
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
} catch (error) {
fail(res, error);
}
};
// @desc Throw the draft away (back to the current published text)
// @route DELETE /api/privacy/notice/draft
// @access Private/Admin
const discardNoticeDraft = async (req, res) => {
try {
await notice.discardDraft();
res.json({ message: 'Draft discarded.' });
} catch (error) {
fail(res, error);
}
};
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
// @route POST /api/privacy/notice/publish
// @access Private/Admin
const publishNotice = async (req, res) => {
try {
const { sections, changeSummary, requiresAcceptance } = req.body || {};
if (requiresAcceptance && !String(changeSummary || '').trim()) {
res.status(400);
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
}
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
});
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
} catch (error) {
fail(res, error);
}
};
// ─── Operators ────────────────────────────────────────────────────────────────
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
function operatorData(body, { partial = false } = {}) {
const data = {};
for (const f of OPERATOR_FIELDS) {
if (body?.[f] === undefined) continue;
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
data[f] = v || (f === 'location' || f === 'website' ? null : '');
}
if (typeof body?.active === 'boolean') data.active = body.active;
if (Number.isInteger(body?.order)) data.order = body.order;
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
return data;
}
// @desc The operator register
// @route GET /api/privacy/operators
// @access Private/Admin
const listOperators = async (req, res) => {
try {
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/privacy/operators
const createOperator = async (req, res) => {
try {
const data = operatorData(req.body);
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
const op = await prisma.operator.create({ data });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
res.status(201).json(op);
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/privacy/operators/:id
const updateOperator = async (req, res) => {
try {
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Operator not found'); }
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
res.json(op);
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/privacy/operators/:id
const deleteOperator = async (req, res) => {
try {
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Operator not found'); }
await prisma.operator.delete({ where: { id: existing.id } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
res.json({ message: 'Operator removed.' });
} catch (error) {
fail(res, error);
}
};
// ─── Consent log ──────────────────────────────────────────────────────────────
// Filter on the person's name or email.
function personWhere(q) {
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
}
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
// @route GET /api/privacy/consent-log
// @access Private/Admin
const consentLog = async (req, res) => {
try {
const type = String(req.query.type || 'news');
const q = String(req.query.q || '').trim();
const skip = (page(req) - 1) * PAGE_SIZE;
const user = { select: { name: true, email: true } };
if (type === 'news') {
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
const [rows, total] = await Promise.all([
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
prisma.communicationConsent.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
});
}
if (type === 'notice') {
const where = q ? { user: personWhere(q) } : {};
const [rows, total] = await Promise.all([
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
prisma.noticeAcceptance.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
});
}
if (type === 'parental') {
const where = {
status: { in: ['given', 'declined'] },
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
};
const [rows, total] = await Promise.all([
prisma.consentRequest.findMany({
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
}),
prisma.consentRequest.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
})),
});
}
res.status(400);
throw new Error('type must be news, notice or parental');
} catch (error) {
fail(res, error);
}
};
// ─── Access log ───────────────────────────────────────────────────────────────
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
// @route GET /api/privacy/access-log
// @access Private/Admin
const accessLog = async (req, res) => {
try {
const where = {};
if (req.query.action) where.action = String(req.query.action);
if (req.query.sensitive === 'true') where.sensitive = true;
if (req.query.actorId) where.actorId = String(req.query.actorId);
if (req.query.from || req.query.to) {
where.createdAt = {};
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
}
const [rows, total, actors] = await Promise.all([
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
prisma.dataAccessLog.count({ where }),
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
]);
res.json({
total, pageSize: PAGE_SIZE,
actions: ACCESS_ACTIONS,
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip, sensitive: r.sensitive })),
});
} catch (error) {
fail(res, error);
}
};
// ─── Readiness ────────────────────────────────────────────────────────────────
// @desc POPIA readiness checklist with a score
// @route GET /api/privacy/readiness
// @access Private/Admin
const getReadiness = async (req, res) => {
try {
res.json(await require('../utils/readiness').readiness());
} catch (error) {
fail(res, error);
}
};
// ─── Retention ────────────────────────────────────────────────────────────────
async function retentionState() {
const retention = require('../utils/retention');
const { getSetting } = require('../utils/settingsCache');
const current = await retention.getPolicies();
const [preview, lastRun, medicalDays, backupCount] = await Promise.all([
retention.preview(current.policies),
retention.lastRun(),
getSetting('consent_medical_retention_days', ''),
getSetting('backup_retain_count', ''),
]);
return {
...current,
definitions: Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { label: d.label, description: d.description, minMonths: d.minMonths, defaults: d.defaults }])),
preview,
lastRun,
related: { medicalDays: parseInt(medicalDays, 10) || 30, backupCount: parseInt(backupCount, 10) || 14, analyticsDays: 90 },
};
}
// @desc Retention policies, what they'd affect now, and the last run
// @route GET /api/privacy/retention
// @access Private/Admin
const getRetention = async (req, res) => {
try {
res.json(await retentionState());
} catch (error) {
fail(res, error);
}
};
// @desc Save retention policies (body.policies: { key: { enabled, months } })
// @route PUT /api/privacy/retention
// @access Private/Admin
const saveRetention = async (req, res) => {
try {
const saved = await require('../utils/retention').savePolicies(req.body?.policies, { reviewedBy: req.user.name });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'retention_policies_updated', targetType: 'retention', metadata: saved.policies, ip: getClientIp(req) });
res.json(await retentionState());
} catch (error) {
fail(res, error);
}
};
// @desc Run the retention job now
// @route POST /api/privacy/retention/run
// @access Private/Admin
const runRetentionNow = async (req, res) => {
try {
const result = await require('../utils/retention').runRetention(new Date());
res.json({ result, state: await retentionState() });
} catch (error) {
fail(res, error);
}
};
// ─── Breach register ──────────────────────────────────────────────────────────
const BREACH_TEXT = ['title', 'description', 'dataAffected', 'whoAffected', 'consequences', 'actionsTaken', 'adviceToPeople', 'causedBy'];
const BREACH_DATES = ['discoveredAt', 'occurredAt', 'regulatorNotifiedAt', 'peopleNotifiedAt'];
function breachData(body, { partial = false } = {}) {
const data = {};
for (const f of BREACH_TEXT) {
if (body?.[f] === undefined) continue;
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'title' ? 200 : 10000);
data[f] = v || (['title', 'description', 'dataAffected'].includes(f) ? '' : null);
}
for (const f of BREACH_DATES) {
if (body?.[f] === undefined) continue;
if (body[f] === null || body[f] === '') { data[f] = null; continue; }
const d = new Date(body[f]);
if (Number.isNaN(d.getTime())) throw new Error(`${f} is not a valid date.`);
data[f] = d;
}
if (body?.peopleAffected !== undefined) {
const n = body.peopleAffected === null || body.peopleAffected === '' ? null : parseInt(body.peopleAffected, 10);
if (n !== null && (!Number.isInteger(n) || n < 0)) throw new Error('People affected must be a whole number.');
data.peopleAffected = n;
}
if (body?.status !== undefined) {
if (!['open', 'contained', 'closed'].includes(body.status)) throw new Error('status must be open, contained or closed');
data.status = body.status;
}
for (const f of ['title', 'description', 'dataAffected']) {
if ((!partial || f in data) && !data[f]) throw new Error({ title: 'Give it a short title.', description: 'Describe what happened.', dataAffected: 'Say what information was affected.' }[f]);
}
if (!partial && !data.discoveredAt) throw new Error('When was it discovered?');
if (partial && 'discoveredAt' in data && !data.discoveredAt) throw new Error('When was it discovered?');
return data;
}
// @route GET /api/privacy/breaches
const listBreaches = async (req, res) => {
try {
res.json(await prisma.securityBreach.findMany({ orderBy: { discoveredAt: 'desc' }, include: { createdBy: { select: { name: true } } } }));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/privacy/breaches
const createBreach = async (req, res) => {
try {
const breach = await prisma.securityBreach.create({ data: { ...breachData(req.body), createdById: req.user.id } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_recorded', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title }, ip: getClientIp(req) });
res.status(201).json(breach);
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/privacy/breaches/:id
const updateBreach = async (req, res) => {
try {
const existing = await prisma.securityBreach.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Not found'); }
const data = breachData(req.body, { partial: true });
const breach = await prisma.securityBreach.update({ where: { id: existing.id }, data });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_updated', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title, changed: Object.keys(data), status: breach.status }, ip: getClientIp(req) });
res.json(breach);
} catch (error) {
fail(res, error);
}
};
module.exports = {
getReadiness,
getRetention,
saveRetention,
runRetentionNow,
listBreaches,
createBreach,
updateBreach,
getNotice,
acceptMyNotice,
getNoticeDraft,
saveNoticeDraft,
discardNoticeDraft,
publishNotice,
listOperators,
createOperator,
updateOperator,
deleteOperator,
consentLog,
accessLog,
};
@@ -0,0 +1,358 @@
const prisma = require('../config/db');
const { comparePassword } = require('../config/auth');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { isValidZAPhone } = require('../utils/whatsapp');
const privacy = require('../utils/privacy');
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
const REQUEST_LABEL = { access: 'access to your information', correction: 'correction of your information', deletion: 'deletion of your account and personal information' };
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
function sendPrivacyEmail(to, subject, content) {
const { sendMail, buildPrivacyEmail } = require('../utils/email');
if (!to) return;
sendMail({ to, subject, ...buildPrivacyEmail(content) }).catch(() => {});
}
/** Tell the Information Officer (when set) and the admins that a request came in. */
async function notifyAdminsOfRequest(request) {
const to = await privacy.privacyContacts();
const url = `${frontendUrl()}/dashboard/admin/data-requests`;
for (const email of to) {
sendPrivacyEmail(email, `New privacy request: ${request.type}`, {
name: '',
heading: 'New privacy request',
paragraphs: [
`${request.name} (${request.email}) asked for ${REQUEST_LABEL[request.type]}.`,
...(request.details ? [`Their message: ${request.details}`] : []),
`POPIA asks for an answer within 30 days — by ${request.dueAt.toLocaleDateString('en-ZA')}. Open the Data requests page to handle it: ${url}`,
],
});
}
}
/**
* "Delete my account" (password already checked by the caller): lock the account straight
* away and file a deletion request. The personal information is removed when an admin
* approves — registrations for upcoming events have to be sorted out first.
*/
async function requestAccountDeletion(user, { details = null } = {}) {
const request = await privacy.createDataRequest({ user, type: 'deletion', details: details ? String(details).trim() : null });
await prisma.user.update({ where: { id: user.id }, data: { isActive: false, tokenVersion: { increment: 1 }, newsEmail: false, newsWhatsApp: false } });
await require('../utils/twoFactor').clearTrustedDevices(user.id);
const blockers = await privacy.deletionBlockers(user.id);
sendPrivacyEmail(user.email, 'We received your request to delete your account', {
name: user.name,
heading: 'Deletion request received',
paragraphs: [
'We received your request to delete your account and personal information. Your account has been closed, so you can no longer log in.',
blockers.length
? `You still have a registration for ${blockers.map(b => b.eventTitle).join(', ')}. Your information is deleted after that, or sooner if the registration is cancelled. Contact us if you'd like to cancel it.`
: 'We will remove your personal information within 30 days and let you know when it is done.',
'Records we must keep by law, such as payment amounts and dates, are kept without your name on them.',
'If you did not ask for this, contact us immediately.',
],
});
notifyAdminsOfRequest(request).catch(() => {});
return request;
}
// ─── The person themselves ────────────────────────────────────────────────────
// @desc My privacy settings: news opt-in, consent history, my requests
// @route GET /api/privacy/me
// @access Private
const getMyPrivacy = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { phoneNumber: true, notificationPreference: true, newsEmail: true, newsWhatsApp: true, newsAskedAt: true },
});
if (!user) { res.status(404); throw new Error('User not found'); }
const [history, requests] = await Promise.all([
privacy.consentHistory(req.user.id),
prisma.dataRequest.findMany({
where: { userId: req.user.id, selfService: false },
orderBy: { createdAt: 'desc' },
select: { id: true, type: true, status: true, details: true, response: true, createdAt: true, closedAt: true },
}),
]);
res.json({
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
newsAskedAt: user.newsAskedAt,
canWhatsApp: isValidZAPhone(user.phoneNumber),
notificationPreference: user.notificationPreference,
history,
requests,
});
} catch (error) {
fail(res, error);
}
};
// @desc Change my News & announcements opt-in
// @route PUT /api/privacy/me/news
// @access Private
const updateMyNews = async (req, res) => {
try {
const { email, whatsapp, source } = req.body || {};
// 'prompt' = the one-time question after login; anything else is the privacy centre.
const fromPrompt = source === 'prompt';
const updated = await privacy.setNewsPreferences(
req.user.id,
{ email: typeof email === 'boolean' ? email : undefined, whatsapp: typeof whatsapp === 'boolean' ? whatsapp : undefined },
{ source: fromPrompt ? 'prompt' : 'privacy_centre', ip: getClientIp(req), answered: true, logAll: fromPrompt },
);
if (!updated) { res.status(404); throw new Error('User not found'); }
res.json({ news: { email: updated.newsEmail, whatsapp: updated.newsWhatsApp }, newsAskedAt: updated.newsAskedAt });
} catch (error) {
fail(res, error);
}
};
// @desc Download my data (JSON, or a readable PDF with ?format=pdf)
// @route GET /api/privacy/me/export
// @access Private
const exportMyData = async (req, res) => {
try {
// An admin viewing as this person must not walk off with their whole record.
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
const data = await privacy.exportUserData(req.user.id);
if (!data) { res.status(404); throw new Error('User not found'); }
// Kept as a closed access request: the record that the person got their data.
await privacy.createDataRequest({ user: { id: req.user.id, name: data.account.name, email: data.account.email }, type: 'access', selfService: true });
const stamp = new Date().toISOString().slice(0, 10);
if (req.query.format === 'pdf') {
const pdf = await privacy.dataExportPdf(data);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.pdf"`);
return res.send(pdf);
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.json"`);
res.send(JSON.stringify(data, null, 2));
} catch (error) {
fail(res, error);
}
};
// @desc Make a privacy request: correction (details required) or deletion (password required)
// @route POST /api/privacy/me/requests
// @access Private
const createMyRequest = async (req, res) => {
try {
const { type, details, password } = req.body || {};
if (!['correction', 'deletion'].includes(type)) { res.status(400); throw new Error('type must be correction or deletion'); }
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type, status: 'open' } });
if (open) { res.status(409); throw new Error(`You already have an open ${type} request.`); }
if (type === 'correction') {
if (!details || !String(details).trim()) { res.status(400); throw new Error('Tell us what needs correcting.'); }
const request = await privacy.createDataRequest({ user, type, details: String(details).trim() });
notifyAdminsOfRequest(request).catch(() => {});
return res.status(201).json({ request, message: 'Your request was sent. We will get back to you by email.' });
}
if (!(await comparePassword(password || '', user.password))) { res.status(400); throw new Error('Incorrect password'); }
const request = await requestAccountDeletion(user, { details });
res.status(201).json({ request, message: 'Your account is closed and your deletion request was sent.' });
} catch (error) {
fail(res, error);
}
};
// ─── Unsubscribe links (no login) ─────────────────────────────────────────────
// @desc What an unsubscribe link refers to
// @route GET /api/privacy/unsubscribe/:token
// @access Public (signed token)
const getUnsubscribe = async (req, res) => {
try {
const info = await privacy.describeUnsubscribe(req.params.token);
if (!info) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
res.json(info);
} catch (error) {
fail(res, error);
}
};
// @desc Unsubscribe from news (body.channels: ['email','whatsapp'], default both)
// @route POST /api/privacy/unsubscribe/:token
// @access Public (signed token)
const postUnsubscribe = async (req, res) => {
try {
const channels = Array.isArray(req.body?.channels) ? req.body.channels : undefined;
const result = await privacy.unsubscribe(req.params.token, { channels, ip: getClientIp(req) });
if (!result) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
res.json({ message: 'You have been unsubscribed.' });
} catch (error) {
fail(res, error);
}
};
// @desc One-click unsubscribe (RFC 8058) from the List-Unsubscribe header — email only
// @route POST /api/privacy/unsubscribe/:token/one-click
// @access Public (signed token)
const oneClickUnsubscribe = async (req, res) => {
try {
await privacy.unsubscribe(req.params.token, { channels: ['email'], ip: getClientIp(req) });
} catch { /* mail apps ignore the body; never fail loudly */ }
res.status(200).send('Unsubscribed');
};
// @desc A person opening the List-Unsubscribe link in a browser: send them to the page
// @route GET /api/privacy/unsubscribe/:token/one-click
// @access Public
const oneClickRedirect = (req, res) => {
res.redirect(302, `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(req.params.token)}`);
};
// ─── Admin: data requests inbox ───────────────────────────────────────────────
// @desc Data requests (?status=open|closed|all, default open)
// @route GET /api/privacy/requests
// @access Private/Admin
const listRequests = async (req, res) => {
try {
const status = String(req.query.status || 'open');
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
handledBy: r.handledBy?.name || null,
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
} catch (error) {
fail(res, error);
}
};
async function loadOpenRequest(req, res) {
const request = await prisma.dataRequest.findUnique({ where: { id: req.params.id } });
if (!request) { res.status(404); throw new Error('Request not found'); }
if (request.status !== 'open') { res.status(409); throw new Error('This request is already closed.'); }
return request;
}
// @desc Complete a request. Deletion: anonymises the account (refused while it has a
// registration for an upcoming event). body.response is emailed to the person.
// @route POST /api/privacy/requests/:id/complete
// @access Private/Admin
const completeRequest = async (req, res) => {
try {
const request = await loadOpenRequest(req, res);
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : null;
if (request.type === 'correction' && !response) { res.status(400); throw new Error('Say what was corrected — it is emailed to the person.'); }
let counts = null;
if (request.type === 'deletion' && request.userId) {
const blockers = await privacy.deletionBlockers(request.userId);
if (blockers.length) {
res.status(409);
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
}
counts = await privacy.anonymiseUser(request.userId);
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: request.userId,
metadata: { dataRequestId: request.id, ...counts }, ip: getClientIp(req),
});
}
const closed = await prisma.dataRequest.update({
where: { id: request.id },
data: { status: 'completed', response, closedAt: new Date(), handledById: req.user.id },
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_completed', targetType: 'data_request', targetId: request.id,
metadata: { type: request.type, name: request.name }, ip: getClientIp(req),
});
sendPrivacyEmail(request.email, request.type === 'deletion' ? 'Your personal information has been deleted' : 'Your privacy request has been completed', {
name: request.name,
heading: request.type === 'deletion' ? 'Your information has been deleted' : 'Request completed',
paragraphs: [
request.type === 'deletion'
? 'Your account and personal information have been deleted. Records we must keep by law, such as payment amounts and dates, are kept without your name on them. Backups that still contain your information are deleted as they age out.'
: `We have completed your request for ${REQUEST_LABEL[request.type]}.`,
...(response ? [response] : []),
],
});
res.json({ request: closed, anonymised: counts });
} catch (error) {
fail(res, error);
}
};
// @desc Reject or cancel a request (body.response says why; emailed). Rejecting a
// deletion request opens the account again (e.g. the person changed their mind).
// @route POST /api/privacy/requests/:id/reject
// @access Private/Admin
const rejectRequest = async (req, res) => {
try {
const request = await loadOpenRequest(req, res);
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : '';
if (!response) { res.status(400); throw new Error('Give a reason — it is emailed to the person.'); }
const cancelled = req.body?.cancelled === true;
if (request.type === 'deletion' && request.userId) {
await prisma.user.update({ where: { id: request.userId }, data: { isActive: true } });
}
const closed = await prisma.dataRequest.update({
where: { id: request.id },
data: { status: cancelled ? 'cancelled' : 'rejected', response, closedAt: new Date(), handledById: req.user.id },
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_rejected', targetType: 'data_request', targetId: request.id,
metadata: { type: request.type, name: request.name, cancelled, reactivated: request.type === 'deletion' }, ip: getClientIp(req),
});
sendPrivacyEmail(request.email, cancelled ? 'Your privacy request was cancelled' : 'About your privacy request', {
name: request.name,
heading: cancelled ? 'Request cancelled' : 'About your request',
paragraphs: [
`Your request for ${REQUEST_LABEL[request.type]} was ${cancelled ? 'cancelled' : 'not carried out'}.`,
response,
...(request.type === 'deletion' ? ['Your account is open again — you can log in as before.'] : []),
...(cancelled ? [] : ['If you disagree, you may complain to the Information Regulator (inforegulator.org.za).']),
],
});
res.json({ request: closed });
} catch (error) {
fail(res, error);
}
};
module.exports = {
getMyPrivacy,
updateMyNews,
exportMyData,
createMyRequest,
getUnsubscribe,
postUnsubscribe,
oneClickUnsubscribe,
oneClickRedirect,
listRequests,
completeRequest,
rejectRequest,
notifyAdminsOfRequest,
requestAccountDeletion,
};
@@ -0,0 +1,239 @@
const prisma = require('../config/db');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { getProvider } = require('../payments');
const canReconcile = (req) => !!req.user && ['admin', 'supervisor'].includes(req.user.role);
// The checkout metadata (who/what the payment was for), read by the provider that sent it.
const metadataOf = (tx) => {
const provider = getProvider(tx.provider);
try { return (provider && tx.raw ? provider.parseWebhook(tx.raw).transaction.metadata : null) || {}; } catch { return {}; }
};
const withLabel = (tx) => ({ ...tx, providerLabel: getProvider(tx.provider)?.label || tx.provider, metadata: metadataOf(tx) });
// @desc Payment provider transactions (optionally filter by provider, reconciled, ignored)
// @route GET /api/provider-transactions (also /api/yoco-transactions)
// @access Private/Admin or Supervisor
const getAllProviderTransactions = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { reconciled, ignored, provider } = req.query;
const where = {};
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
if (provider) where.provider = String(provider);
const items = await prisma.providerTransaction.findMany({ where, orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }] });
return res.status(200).json({ success: true, data: items.map(withLabel) });
} catch (error) {
console.error('Failed to fetch provider transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Unreconciled provider transactions (not reconciled and not ignored)
// @route GET /api/provider-transactions/unreconciled (also /api/yoco-transactions/unreconciled)
// @access Private/Admin or Supervisor
const getUnreconciledProviderTransactions = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const items = await prisma.providerTransaction.findMany({
where: { reconciled: false, ignored: false, ...(req.query.provider ? { provider: String(req.query.provider) } : {}) },
orderBy: { createdDate: 'desc' },
});
return res.status(200).json({ success: true, data: items.map(withLabel) });
} catch (error) {
console.error('Failed to fetch unreconciled provider transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Reconcile a provider transaction to a Registration or Donation (creates Payment)
// @route POST /api/provider-transactions/:id/reconcile (also /api/yoco-transactions/:id/reconcile)
// @access Private/Admin or Supervisor
const reconcileProviderTransaction = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { id } = req.params;
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
if (tx.reconciled && tx.paymentId) {
return res.status(409).json({ success: false, message: 'Already reconciled', data: withLabel(tx) });
}
// Load registration if provided
let registration = null;
if (registrationId) {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
event: true,
user: true,
registrationOptions: { include: { eventOption: true } },
payments: true
}
});
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
}
// Determine payment fields
const amountFloat = typeof tx.amount === 'number' ? (tx.amount / 100) : 0;
if (!amountFloat || amountFloat <= 0) {
return res.status(400).json({ success: false, message: 'Invalid amount on the transaction for reconciliation' });
}
const metadata = metadataOf(tx);
// Resolve a valid userId for the Payment to satisfy FK constraints
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && metadata.userId) {
try {
const exists = await prisma.user.findUnique({ where: { id: String(metadata.userId) } });
if (exists) resolvedUserId = String(metadata.userId);
} catch {}
}
if (!resolvedUserId && req.user?.id) {
// Fallback to the acting supervisor/admin to avoid FK violations
resolvedUserId = req.user.id;
}
if (!resolvedUserId) {
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
}
const paymentData = {
amount: amountFloat,
method: tx.methodType || 'card',
provider: tx.provider,
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
externalId: tx.externalId || undefined,
status: 'completed'
};
// Ensure donation has an eventId
if (!registration && !paymentData.eventId) {
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
}
// Keep the provider's creation time so early-bird pricing is evaluated correctly
const createdAt = tx.createdDate || tx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Update registration status and generate tickets if paid. Ticket generation stays
// synchronous so `generatedTickets` can be in the response; emailing is backgrounded below.
let generatedTickets = [];
if (registration) {
try {
const updatedReg = await updateRegistrationStatus(registration.id);
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
} catch (genErr) {
console.error('Error generating tickets after reconciliation:', genErr);
}
}
} catch (e) {
// log but do not fail reconciliation
console.warn('Failed to update registration after reconciliation:', e?.message || e);
}
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after reconciliation:', e); }
}
})();
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: payment.id } });
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment, generatedTickets: generatedTickets.length ? generatedTickets : undefined } });
} catch (error) {
console.error('Failed to reconcile provider transaction:', error);
// A Payment with the same externalId already exists: link to it instead
if (error?.code === 'P2002') {
try {
const tx = await prisma.providerTransaction.findUnique({ where: { id: req.params.id } });
const existing = tx ? await prisma.payment.findFirst({ where: { externalId: tx.externalId } }) : null;
if (existing) {
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: existing.id } });
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment: existing }, message: 'Linked to existing payment' });
}
} catch {}
}
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
// Minimal registration status update mirroring webhook logic
async function updateRegistrationStatus(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true
}
});
if (!registration) return null;
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
let newStatus = 'pending';
if (totalPaid >= totalDue) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
}
// @desc Ignore a provider transaction (mark as ignored and reconciled without creating a payment)
// @route POST /api/provider-transactions/:id/ignore (also /api/yoco-transactions/:id/ignore)
// @access Private/Admin or Supervisor
const ignoreProviderTransaction = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { id } = req.params;
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
if (tx.reconciled && tx.ignored) {
return res.status(200).json({ success: true, data: withLabel(tx), message: 'Already ignored' });
}
const updated = await prisma.providerTransaction.update({ where: { id }, data: { ignored: true, reconciled: true } });
return res.status(200).json({ success: true, data: withLabel(updated) });
} catch (error) {
console.error('Failed to ignore provider transaction:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
module.exports = {
getAllProviderTransactions,
getUnreconciledProviderTransactions,
reconcileProviderTransaction,
ignoreProviderTransaction
};
File diff suppressed because it is too large Load Diff
@@ -397,6 +397,13 @@ function writeTableRows(ws, startRow, columns, rows) {
return { nextRow: r, dataStartRow };
}
// POPIA access log: who took which report out of the system, and how many rows.
function logReport(req, action) {
const { logDataAccess, reportRowCount } = require('../utils/accessLog');
const p = req.body || {};
logDataAccess(req, { action, what: [p.title || 'Report', p.subtitle].filter(Boolean).join(' — '), records: reportRowCount(p) });
}
// POST /api/reports/pdf
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
@@ -410,6 +417,7 @@ const generatePdf = async (req, res) => {
doc.pipe(res);
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end();
logReport(req, 'report_pdf');
} catch (e) {
res.status(400).json({ message: e.message });
}
@@ -426,6 +434,7 @@ const generateExcel = async (req, res) => {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
logReport(req, 'report_excel');
} catch (e) {
res.status(400).json({ message: e.message });
}
@@ -462,6 +471,7 @@ const emailPdf = async (req, res) => {
try { fs.unlinkSync(filePath); } catch {}
logReport(req, 'report_email');
res.json({ message: `Report emailed to ${user.email}` });
} catch (e) {
res.status(400).json({ message: e.message });
@@ -489,6 +499,7 @@ const whatsappPdf = async (req, res) => {
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
logReport(req, 'report_whatsapp');
res.json({ message: `Report sent to your WhatsApp` });
} catch (e) {
res.status(400).json({ message: e.message });
@@ -0,0 +1,151 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const audit = (req, action, lock, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
ip: getClientIp(req),
});
const lockView = (l) => ({
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
});
/** The lock, if the caller is the person holding it on this device. */
async function ownLock(req, res) {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
const deviceId = req.body?.deviceId || req.query?.deviceId;
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
return lock;
}
// @desc Active offline locks for an event
// @route GET /api/scan-locks?eventId=
// @access Private/Staff
const listLocks = async (req, res) => {
try {
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
} catch (error) {
fail(res, error);
}
};
// @desc Lock a section (or the whole event) to this device for offline scanning
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
// @access Private/Staff
const createLock = async (req, res) => {
try {
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
audit(req, 'scan_lock_acquired', lock);
res.status(201).json(lockView(lock));
} catch (error) {
fail(res, error);
}
};
// @desc Download the tickets valid for this lock, for offline scanning
// @route GET /api/scan-locks/:id/snapshot?deviceId=
// @access Private/Staff (lock holder)
const getSnapshot = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
} catch (error) {
fail(res, error);
}
};
// @desc Upload scans made offline
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
const syncLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
const result = await syncScans(lock, req.body?.scans);
if (result.accepted || result.conflicts.length) {
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
}
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
} catch (error) {
fail(res, error);
}
};
// @desc End offline mode (the device should sync first)
// @route DELETE /api/scan-locks/:id { deviceId }
// @access Private/Staff (lock holder)
const releaseLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (!lock.releasedAt) {
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
audit(req, 'scan_lock_released', lock);
}
res.json({ message: 'Offline mode ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Release someone else's lock (e.g. a lost or flat phone)
// @route POST /api/scan-locks/:id/force-release
// @access Private/Supervisor
const forceRelease = async (req, res) => {
try {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
if (lock.releasedAt) return res.json({ message: 'Already released.' });
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
} catch (error) {
fail(res, error);
}
};
// @desc Offline sync reports for an event (with anything that needs review)
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
// @access Private/Supervisor
const listReports = async (req, res) => {
try {
const where = {
scanLock: { eventId: String(req.query.eventId || '') },
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
};
const reports = await prisma.offlineSyncReport.findMany({
where,
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
} catch (error) {
fail(res, error);
}
};
// @desc Mark a sync report as reviewed
// @route POST /api/scan-locks/reports/:id/review
// @access Private/Supervisor
const reviewReport = async (req, res) => {
try {
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
res.json(report);
} catch (error) {
fail(res, error);
}
};
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };
+16 -4
View File
@@ -8,6 +8,13 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Payment provider settings are only readable (secrets masked) and writable through
// /api/yoco and /api/payfast — never through the generic settings endpoints.
const PAYMENT_PRIVATE_KEYS = new Set([
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -17,6 +24,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',
@@ -44,6 +53,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -79,7 +89,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -118,7 +128,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -172,7 +182,9 @@ const setupRegister = async (req, res) => {
},
});
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
// wizard; the admin enrols at their first normal login.
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
res.json({ token, name: user.name, email: user.email });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
@@ -193,7 +205,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -0,0 +1,111 @@
const payments = require('../payments');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
const fail = (res, error) => res.status(error.statusCode || error.response?.status || 400).json({ message: safeErrorMessage(error) });
async function sponsorableEvent(eventId) {
const event = await prisma.event.findUnique({ where: { id: String(eventId || '') }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event || !event.isActive) throw sponsorshipError('Event not found', 404);
if (!event.allowSponsorships) throw sponsorshipError('This event isn’t taking sponsorships.');
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) throw sponsorshipError('This event has ended and is no longer taking sponsorships.');
return event;
}
async function poolFor(event) {
const sponsorships = await prisma.payment.findMany({ where: { eventId: event.id, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, select: { id: true, amount: true } });
const legs = sponsorships.length
? await prisma.payment.findMany({ where: { originalPaymentId: { in: sponsorships.map(s => s.id) }, isDonation: false }, select: { originalPaymentId: true, amount: true } })
: [];
return poolSummary(sponsorships, legs, spotPrice(event));
}
// @desc Public sponsorship progress for an event (never names who was helped)
// @route GET /api/sponsorships/event/:eventId
// @access Public
const getEventSponsorship = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event || !event.isActive || !event.allowSponsorships) return res.json({ enabled: false });
const pool = await poolFor(event);
const open = event.cashupStatus !== 'closed' && new Date(event.endDate) >= new Date();
res.json({
enabled: true,
open,
spotPrice: spotPrice(event),
showProgress: event.showSponsorshipProgress,
goalSpots: event.sponsorshipGoalSpots,
spotsFunded: event.showSponsorshipProgress ? pool.spotsFunded : null,
sponsors: event.showSponsorshipProgress ? pool.sponsors : null,
});
} catch (error) {
fail(res, error);
}
};
// @desc Start an online checkout to sponsor spots (or a custom amount) at an event
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
// @access Public (guests give a name and email for the receipt)
const createSponsorCheckout = async (req, res) => {
try {
const { eventId, spots, amount, anonymous, sponsorName, message, name, email, phone } = req.body || {};
const event = await sponsorableEvent(eventId);
const charge = sponsorshipCharge({ spots, amount }, spotPrice(event));
let payer = req.user || null;
if (!payer) {
const { hasValidEmail, findOrCreateAccount } = require('../utils/accountMatch');
const cleanName = String(name || '').trim().slice(0, 100);
if (!cleanName) throw sponsorshipError('Enter your name.');
if (!hasValidEmail(email)) throw sponsorshipError('Enter your email address so we can send your receipt.');
// An existing account is reused; otherwise a quiet inactive one holds the receipt —
// no activation link is sent, since a sponsor doesn't need to log in.
payer = (await findOrCreateAccount({ name: cleanName, email: String(email).trim(), phone })).user;
}
const isAnonymous = anonymous === true;
const displayName = isAnonymous ? null : (String(sponsorName || '').trim() || payer.name || '').slice(0, 100) || null;
const note = String(message || '').trim().slice(0, 300) || null;
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const back = `${base}/events/${encodeURIComponent(event.id)}`;
const amountInCents = Math.round(charge.amount * 100);
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
const checkout = await payments.createCheckout(req.body?.provider || null, {
amountCents: amountInCents,
metadata: {
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
sponsorAnonymous: isAnonymous ? 'true' : 'false',
...(displayName ? { sponsorName: displayName } : {}),
...(note ? { sponsorMessage: note } : {}),
...(charge.spots ? { spots: String(charge.spots) } : {}),
},
successUrl: `${back}?sponsored=1`,
cancelUrl: back,
failureUrl: `${back}?sponsored=failed`,
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
description: label,
payer: { name: payer.name || '', email: payer.email || '' },
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
});
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
} catch (error) {
if (error.statusCode >= 500) console.error('Sponsor checkout error:', error.message);
fail(res, error);
}
};
// @desc The sponsorship pool for an event (supervisors)
// @route GET /api/sponsorships/event/:eventId/pool
// @access Private/Supervisor
const getPool = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event) throw sponsorshipError('Event not found', 404);
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
} catch (error) {
fail(res, error);
}
};
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
ticketsSold: ticketSumById[r.eventId] || 0,
}));
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
}
} catch (e) {
console.warn('[stats] funnel tile failed:', e?.message || e);
}
res.json({
funnel,
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
+401 -39
View File
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
// @access Private
const getUserTickets = async (req, res) => {
try {
// Tickets this user paid for, plus named tickets someone else bought for them.
const tickets = await prisma.ticket.findMany({
where: {
userId: req.user.id,
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
@@ -91,10 +92,19 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
event: true,
attendee: {
select: {
id: true, name: true,
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
},
},
user: { select: { id: true, name: true } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
@@ -253,19 +263,24 @@ const getScanPreview = async (req, res) => {
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
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",
COALESCE(
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
@@ -276,22 +291,50 @@ const getScanPreview = async (req, res) => {
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
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, new Date(), r.chosenSessionIds);
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 },
@@ -299,7 +342,8 @@ const getScanPreview = async (req, res) => {
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
usages: r.usages || []
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -319,6 +363,9 @@ const scanTicket = async (req, res) => {
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -327,7 +374,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
@@ -338,10 +386,13 @@ 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) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -363,11 +414,56 @@ const scanTicket = async (req, res) => {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
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, new Date(), r.chosenSessionIds);
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,
@@ -375,6 +471,25 @@ const scanTicket = async (req, res) => {
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
@@ -391,11 +506,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() }
})
]);
@@ -407,8 +524,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,
@@ -430,8 +549,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, 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' },
@@ -641,6 +762,208 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
// Tell the new holder: activation link for a new account, their ticket, and
// anything they need to do themselves (consent, attendee form).
if (r.accountCreated) {
const { sendActivationLink } = require('./userController');
sendActivationLink(r.toUser);
}
const a = r.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
const silent = { status: () => silent, json: () => silent };
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
if (r.needsConsent) {
const { createConsentRequest } = require('../utils/consent');
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
}
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
if (form && form.fields.length > 0) {
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
sendAttendeeFormLinkMessage(full).catch(() => {});
}
res.json({
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Transfer history of a ticket
// @route GET /api/tickets/:id/transfers
// @access Private (payer or holder; staff)
const getTicketTransfers = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
const transfers = await prisma.ticketTransfer.findMany({
where: { ticketId: req.params.id },
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(transfers);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
function allocationLabel(attendee) {
return [...(attendee?.allocations || [])]
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
.map(a => `${a.set.name}: ${a.group.name}`)
.join(' · ');
}
// One ticket card on the PDF, matching the printed design in frontend/src/lib/ticketPrint.ts.
function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
const pad = 12; const radius = 9;
const innerW = w - pad * 2;
const gray700 = '#374151'; const gray500 = '#6b7280';
doc.save();
doc.roundedRect(x, y + 1.5, w, h, radius).fillOpacity(0.06).fill('#000000');
doc.restore();
doc.save();
doc.roundedRect(x, y, w, h, radius).lineWidth(0.75).fillAndStroke('#ffffff', '#e5e7eb');
doc.restore();
// 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 = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session, null, ticket.registrationOption.sessionIds)
|| (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 });
const titleW = innerW - (dateStr ? dateW + 8 : 0);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#111827');
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
// Ticket type
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
doc.font('Helvetica').fontSize(10).fillColor(gray700).text(type, x + pad, cy, { width: innerW });
cy += doc.heightOfString(type, { width: innerW }) + 2;
// Name (named ticket) or quantity, value in bold
doc.font('Helvetica').fontSize(9).fillColor(gray700)
.text(ticket.attendee ? 'Name: ' : 'Qty: ', x + pad, cy, { continued: true })
.font('Helvetica-Bold').text(ticket.attendee ? ticket.attendee.name : String(ticket.quantity || 1));
cy += 12;
const groupLabel = allocationLabel(ticket.attendee);
if (groupLabel) {
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(groupLabel, x + pad, cy, { width: innerW });
cy += doc.heightOfString(groupLabel, { width: innerW }) + 2;
}
// Footer lines are measured first so the QR code takes the remaining space
const footer = [`Ticket ID: ${ticket.id}`, ...(buyerName ? [`Purchased by: ${buyerName}`] : [])];
doc.font('Helvetica').fontSize(8.5);
const footerH = footer.reduce((sum, line) => sum + doc.heightOfString(line, { width: innerW }) + 2, 0);
const qrSpace = (y + h - pad - footerH) - (cy + 6) - 6;
const qrSize = Math.max(60, Math.min(130, qrSpace));
const qrY = cy + 6 + Math.max(0, (qrSpace - qrSize) / 2);
doc.image(qrDataUrl, x + (w - qrSize) / 2, qrY, { width: qrSize, height: qrSize });
let fy = y + h - pad - footerH;
doc.fillColor(gray500);
for (const line of footer) {
doc.text(line, x + pad, fy, { width: innerW, align: 'center' });
fy += doc.heightOfString(line, { width: innerW }) + 2;
}
doc.fillColor('#000000');
}
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
@@ -660,6 +983,13 @@ async function emailTicketsInternal(req, res, userOverride) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const ticketInclude = {
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,
};
let tickets;
if (registrationId) {
const registration = await prisma.registration.findUnique({
@@ -669,22 +999,53 @@ async function emailTicketsInternal(req, res, userOverride) {
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
tickets = await prisma.ticket.findMany({
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
} else {
// The payer, or the named holder of a group ticket, can have it sent.
tickets = await prisma.ticket.findMany({
where: { id: { in: ticketIds }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
}
// Dedup by registrationOptionId
// Group tickets set to "send each person their own": attendees with contact details
// get theirs directly; the rest (and attendees without contact details) go to the
// payer below. Not when the caller asked for a specific recipient or channel.
if (!userOverride && !req._noSplit) {
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
&& t.registrationOption?.registration?.ticketDelivery === 'each');
for (const t of own) {
const a = t.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
const silent = { status: () => silent, json: () => silent };
try {
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
} catch (e) {
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
}
}
const sentIds = new Set(own.map(t => t.id));
tickets = tickets.filter(t => !sentIds.has(t.id));
if (tickets.length === 0 && own.length > 0) {
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
}
}
// Dedup by registrationOptionId — except named tickets, which are one per person.
{
const seen = new Map(); const deduped = [];
for (const t of tickets) {
const key = t.registrationOptionId;
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
else {
const existing = seen.get(key);
@@ -709,31 +1070,22 @@ async function emailTicketsInternal(req, res, userOverride) {
const doc = new PDFDocument({ size: 'A4', margin: 20 });
const writeStream = fs.createWriteStream(tempFilePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
const ticketsPerRow = 2; const ticketsPerColumn = 4;
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
// Same card design as the printed tickets (frontend/src/lib/ticketPrint.ts): rounded white
// cards with a light border and soft shadow, two columns, three rows per page.
const margin = 20; const gap = 12;
const ticketsPerRow = 2; const ticketsPerColumn = 3;
const ticketWidth = (doc.page.width - margin * 2 - gap * (ticketsPerRow - 1)) / ticketsPerRow;
const ticketHeight = (doc.page.height - margin * 2 - gap * (ticketsPerColumn - 1)) / ticketsPerColumn;
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
QRCode.toDataURL(ticket.qrCode, { margin: 1, width: 400 }, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
})));
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
let ticketIndex = 0;
for (const ticket of tickets) {
const row = Math.floor(ticketIndex % ticketsPerColumn);
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
doc.font('Helvetica').fontSize(10);
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
const eventDate = new Date(ticket.event.startDate);
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
const slot = ticketIndex % (ticketsPerRow * ticketsPerColumn);
const x = margin + (slot % ticketsPerRow) * (ticketWidth + gap);
const y = margin + Math.floor(slot / ticketsPerRow) * (ticketHeight + gap);
drawTicketCard(doc, { x, y, w: ticketWidth, h: ticketHeight }, ticket, qrCodeMap[ticket.id], ticket.user?.name || user.name);
ticketIndex++;
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
}
@@ -741,9 +1093,13 @@ 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);
// All-sessions tickets with the same sessions ticked: those sessions.
const chosenKeys = new Set(tickets.map(t => (t.registrationOption?.sessionIds || []).join(',')));
const chosenIds = chosenKeys.size === 1 && !pinnedSessions.length ? tickets[0].registrationOption?.sessionIds : null;
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, chosenIds) || (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 = [];
@@ -800,5 +1156,11 @@ module.exports = {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+308 -50
View File
@@ -4,6 +4,18 @@ const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const {
METHODS: TWO_FACTOR_METHODS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
requiresTwoFactor,
enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -124,12 +136,50 @@ async function sendWelcomeEmail(user) {
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
noticeVersionAccepted: true,
};
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
// the controller convention of res.status(...) before throwing.
function errorStatus(error, res) {
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
}
// Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token.
async function completeLogin(user, req, res, extra = {}) {
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
// Retention: logging in keeps the account (and cancels an inactivity warning).
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
// Send login notification in the background
sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion),
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
// accept a privacy notice version marked as a significant change (NoticePrompt).
newsPromptPending: !user.newsAskedAt,
noticePromptPending,
...extra
});
}
// @desc Register a new user
// @route POST /api/users
// @access Public
const registerUser = async (req, res) => {
try {
const { name, email, password, phoneNumber, notificationPreference } = req.body;
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
// Normalize phone using SA-aware normalisation
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
@@ -174,6 +224,18 @@ const registerUser = async (req, res) => {
});
if (user) {
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
// way, so they aren't asked again at their first login.
await require('../utils/privacy').setNewsPreferences(
user.id,
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
// Send welcome email in the background — don't block the response
sendWelcomeEmail(user).catch(() => {});
@@ -198,7 +260,7 @@ const registerUser = async (req, res) => {
// @access Public
const loginUser = async (req, res) => {
try {
const {email, password} = req.body;
const {email, password, deviceToken} = req.body;
const rawInput = email;
@@ -278,20 +340,179 @@ const loginUser = async (req, res) => {
const updated = await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockUntil: null },
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
select: LOGIN_USER_SELECT,
});
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
// Two-factor: the password alone isn't enough unless this browser was
// remembered earlier. Send a code and hand back a challenge instead of a token.
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
return res.json({ twoFactorRequired: true, ...challenge });
}
res.json({
id: updated.id,
name: updated.name,
email: updated.email,
role: updated.role,
token: generateToken(updated.id, updated.role, updated.tokenVersion)
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
// first channel that can deliver one, and verifying it turns 2FA on. A
// trusted device can't skip this — nothing has been verified yet.
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
const method = await enforcementMethod(updated);
if (method) {
const challenge = await createChallenge(updated, 'login', method);
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
}
}
await completeLogin(updated, req, res);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Second login step — exchange an emailed/WhatsApp code for a token
// @route POST /api/users/login/2fa
// @access Public
const verifyLoginTwoFactor = async (req, res) => {
try {
const { challengeId, code, rememberDevice } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) {
res.status(401);
throw new Error('Your account has been deactivated');
}
// First login since 2FA became required for this role: the code proves the
// channel works, so turn 2FA on with it.
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
user = await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
select: LOGIN_USER_SELECT,
});
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
const extra = {};
if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
}
await completeLogin(user, req, res, extra);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Send a new code for a pending login challenge
// @route POST /api/users/login/2fa/resend
// @access Public
const resendLoginTwoFactor = async (req, res) => {
try {
const { challengeId } = req.body || {};
res.json(await resendChallenge(challengeId, 'login'));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
// @route POST /api/users/2fa/enable
// @access Private
const startEnableTwoFactor = async (req, res) => {
try {
const { method, password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(400);
throw new Error('Incorrect password');
}
if (!TWO_FACTOR_METHODS.includes(method)) {
res.status(400);
throw new Error('Choose email or WhatsApp for your verification codes');
}
if (!(await availableMethods(user)).includes(method)) {
res.status(400);
throw new Error(method === 'whatsapp'
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
: 'Add a valid email address to your profile before turning on two-factor authentication.');
}
// Unlike login, don't fall back to email here — the point of this step is to
// prove the channel the user picked actually reaches them.
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Finish turning on 2FA with the code that was sent
// @route POST /api/users/2fa/confirm
// @access Private
const confirmEnableTwoFactor = async (req, res) => {
try {
const { challengeId, code } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
await prisma.user.update({
where: { id: req.user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
});
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Turn off 2FA
// @route POST /api/users/2fa/disable
// @access Private
const disableTwoFactor = async (req, res) => {
try {
const { password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
if (requiresTwoFactor(user)) {
res.status(403);
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
}
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(400);
throw new Error('Incorrect password');
}
await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
});
await clearTrustedDevices(user.id);
if (user.twoFactorEnabled) {
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Re-confirm the logged-in user's password without starting a new session
// (the self-service kiosk uses this before letting the event be changed)
// @route POST /api/users/verify-password
// @access Private
const verifyPassword = async (req, res) => {
try {
const { password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(401);
throw new Error('Incorrect password');
}
res.json({ ok: true });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
@@ -311,14 +532,22 @@ const getUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true
isActive: true,
newsAskedAt: true,
noticeVersionAccepted: true
}
});
if (user) {
res.json(user);
const { newsAskedAt, noticeVersionAccepted, ...rest } = user;
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
// noticePromptPending: a privacy notice version they must accept has been published.
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
} else {
res.status(404);
throw new Error('User not found');
@@ -375,6 +604,11 @@ const updateUserProfile = async (req, res) => {
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// 2FA codes can't go to WhatsApp without a usable number — move them to email
// rather than leave the account pointing at a channel that no longer exists.
const { isValidZAPhone } = require('../utils/whatsapp');
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
// Update user data
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
@@ -384,6 +618,7 @@ const updateUserProfile = async (req, res) => {
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
updatedAt: new Date()
},
select: {
@@ -393,6 +628,8 @@ const updateUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
@@ -402,6 +639,7 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
await clearTrustedDevices(updatedUser.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
@@ -414,6 +652,7 @@ const updateUserProfile = async (req, res) => {
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
@@ -432,7 +671,7 @@ const getUsers = async (req, res) => {
const select = {
id: true, name: true, email: true, role: true,
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
};
// Build filters
@@ -559,7 +798,7 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
@@ -580,6 +819,21 @@ const updateUser = async (req, res) => {
data.password = await hashPassword(password.trim());
}
// Admins can switch 2FA off for someone who has lost access to their codes,
// but never on — enabling requires the user to prove the channel themselves.
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
if (resetTwoFactor) {
data.twoFactorEnabled = false;
data.twoFactorMethod = null;
}
// Promoted to a role that requires 2FA without having it: end their sessions
// so the next login enrols them.
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
data.tokenVersion = { increment: 1 };
}
const updatedUser = await prisma.user.update({
where: { id: req.params.id },
data,
@@ -590,12 +844,20 @@ const updateUser = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
createdAt: true,
updatedAt: true,
isActive: true
}
});
if (resetTwoFactor || data.password) {
await clearTrustedDevices(updatedUser.id);
}
if (resetTwoFactor) {
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json(updatedUser);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -639,16 +901,21 @@ const anonymizeUser = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
await prisma.user.update({
where: { id: req.params.id },
data: {
name: 'Deleted User',
email: `deleted-${req.params.id}@deleted.local`,
phoneNumber: null,
isActive: false,
tokenVersion: { increment: 1 },
updatedAt: new Date()
}
const { deletionBlockers, anonymiseUser } = require('../utils/privacy');
const blockers = await deletionBlockers(user.id);
if (blockers.length) {
res.status(409);
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
}
const counts = await anonymiseUser(user.id);
// An open deletion request from this person is now done.
await prisma.dataRequest.updateMany({
where: { userId: user.id, type: 'deletion', status: 'open' },
data: { status: 'completed', closedAt: new Date(), handledById: req.user.id },
});
require('../utils/adminAudit').logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: user.id,
metadata: counts, ip: getClientIp(req),
});
res.json({ message: 'User data deleted' });
@@ -754,6 +1021,7 @@ const resetPassword = async (req, res) => {
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
await clearTrustedDevices(user.id);
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
@@ -838,6 +1106,7 @@ const revokeMySession = async (req, res) => {
data: { tokenVersion: { increment: 1 } },
select: { id: true, role: true, tokenVersion: true },
});
await clearTrustedDevices(updated.id);
// Bumping tokenVersion invalidates every existing token, including the one
// this request just used. Issue a fresh token for the current device so it
// isn't logged out too.
@@ -864,6 +1133,7 @@ const adminRevokeUserSessions = async (req, res) => {
where: { id: req.params.id },
data: { tokenVersion: { increment: 1 } },
});
await clearTrustedDevices(req.params.id);
res.json({ message: `Sessions revoked for ${target.name}.` });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -892,30 +1162,11 @@ const closeAccount = async (req, res) => {
const realName = user.name;
if (deleteData) {
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
await prisma.user.update({
where: { id: req.user.id },
data: {
name: 'Deleted User',
email: `deleted-${uuidv4()}@deleted.invalid`,
password: '',
phoneNumber: null,
isActive: false,
tokenVersion: { increment: 1 },
updatedAt: new Date(),
},
});
// Send closure confirmation to the real address (before it was wiped)
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
return res.json({ message: 'Your account and personal data have been removed.' });
// POPIA: deleting personal data is a request an admin approves (see privacyController);
// the account is closed straight away.
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type: 'deletion', status: 'open' } });
if (!open) await require('./privacyController').requestAccountDeletion(user);
return res.json({ message: 'Your account is closed. Your personal information will be deleted once your request has been processed — we will email you.' });
} else {
// Soft-deactivate only
await prisma.user.update({
@@ -926,6 +1177,7 @@ const closeAccount = async (req, res) => {
updatedAt: new Date(),
},
});
await clearTrustedDevices(req.user.id);
// Send closure confirmation
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
@@ -957,6 +1209,12 @@ const getMyActivity = async (req, res) => {
module.exports = {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -0,0 +1,213 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { findAccountByContact } = require('../utils/accountMatch');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken, placesFree, lineName } = require('../utils/waitlist');
const { activeSessions, isPickOption, sessionLabel } = require('../utils/sessions');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const ENTRY_INCLUDE = {
event: { select: { id: true, title: true, startDate: true, picture: true } },
eventOption: { select: { id: true, name: true } },
variant: { select: { id: true, name: true } },
session: { select: { id: true, label: true, startsAt: true, endsAt: true } },
};
// @desc Join the waitlist for a sold-out option
// @route POST /api/waitlist
// @access Public (login needed when the event requires it)
// Body: { eventId, eventOptionId, variantId?, sessionId?, quantity, guestName?, guestEmail?, guestPhone? }
// sessionId: required for a "pick sessions" option (one session per entry), not allowed otherwise.
const joinWaitlist = async (req, res) => {
try {
const { eventId, eventOptionId, variantId = null, sessionId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const qty = parseInt(quantity, 10);
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } }, sessions: true } });
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
const option = event.eventOptions.find(o => o.id === eventOptionId);
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
if (isPickOption(event, option)) {
if (!sessionId) { res.status(400); throw new Error(`Choose which session of "${option.name}" you want to wait for.`); }
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
if (!session) { res.status(400); throw new Error('That session isn\'t available.'); }
if (new Date(session.startsAt) <= new Date()) { res.status(400); throw new Error(`${sessionLabel(session, event.sessions)} has already started.`); }
} else if (sessionId) {
res.status(400); throw new Error(`"${option.name}" covers every session — no session to choose.`);
}
const free = await placesFree(event, option, variant, sessionId);
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
// Who's joining
let userId = req.user?.id;
if (!userId) {
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
userId = match.user?.id || (await prisma.user.create({
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
})).id;
}
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, sessionId, status: { in: ACTIVE } } });
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
const entry = await prisma.waitlistEntry.create({
data: { eventId, eventOptionId, variantId, sessionId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
include: ENTRY_INCLUDE,
});
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, sessionId, status: 'waiting', position: { lt: entry.position } } });
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
} catch (error) {
fail(res, error);
}
};
// @desc The caller's waitlist entries
// @route GET /api/waitlist/mine
// @access Private
const myWaitlist = async (req, res) => {
try {
const entries = await prisma.waitlistEntry.findMany({
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
include: ENTRY_INCLUDE,
orderBy: { createdAt: 'desc' },
});
res.json(entries);
} catch (error) {
fail(res, error);
}
};
// @desc Leave the waitlist (an open offer is passed on)
// @route DELETE /api/waitlist/:id
// @access Private (owner)
const leaveWaitlist = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
if (entry.status === 'offered') queueWaitlist(entry.eventId);
res.json({ message: 'You\'ve left the waitlist.' });
} catch (error) {
fail(res, error);
}
};
// @desc What a claim link offers
// @route GET /api/waitlist/claim/:token
// @access Public (the token is the credential)
const claimInfo = async (req, res) => {
try {
const entry = await findOfferByToken(req.params.token);
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
res.json({
status: expired ? 'expired' : 'offered',
eventId: entry.eventId,
eventTitle: entry.event.title,
eventStart: entry.session?.startsAt || entry.event.startDate,
eventOptionId: entry.eventOptionId,
optionName: entry.eventOption.name,
variantId: entry.variantId,
variantName: entry.variant?.name || null,
sessionId: entry.sessionId,
sessionName: entry.session ? sessionLabel(entry.session, entry.event.sessions) : null,
sessionStart: entry.session?.startsAt || null,
lineName: lineName(entry),
quantity: entry.offeredQuantity,
requested: entry.quantity,
expiresAt: entry.offerExpiresAt,
name: entry.user?.name,
});
} catch (error) {
fail(res, error);
}
};
// @desc The waitlist for an event
// @route GET /api/waitlist/events/:eventId
// @access Private/Staff
const eventWaitlist = async (req, res) => {
try {
const entries = await prisma.waitlistEntry.findMany({
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
orderBy: { position: 'asc' },
});
res.json(entries);
} catch (error) {
fail(res, error);
}
};
// @desc Offer a place to someone on the waitlist now (even without free stock)
// @route POST /api/waitlist/:id/offer
// @access Private/Supervisor
const offerManually = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id }, include: { session: true } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (entry.session && (entry.session.cancelledAt || new Date(entry.session.startsAt) <= new Date())) {
res.status(400); throw new Error(`The session they're waiting for has ${entry.session.cancelledAt ? 'been cancelled' : 'already started'}.`);
}
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
const updated = await makeOffer(entry.id, qty);
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
} catch (error) {
fail(res, error);
}
};
// @desc Move someone up or down the queue
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
// @access Private/Supervisor
const moveEntry = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
const up = req.body?.direction === 'up';
const neighbour = await prisma.waitlistEntry.findFirst({
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
orderBy: { position: up ? 'desc' : 'asc' },
});
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
await prisma.$transaction([
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
]);
res.json({ message: 'Moved.' });
} catch (error) {
fail(res, error);
}
};
// @desc Take someone off the waitlist (an open offer is passed on)
// @route POST /api/waitlist/:id/remove
// @access Private/Supervisor
const removeEntry = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
if (entry.status === 'offered') queueWaitlist(entry.eventId);
res.json({ message: 'Removed from the waitlist.' });
} catch (error) {
fail(res, error);
}
};
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
+3 -564
View File
@@ -1,511 +1,9 @@
const crypto = require('crypto');
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { handleProviderWebhook } = require('../payments/webhook');
// @desc Handle Yoco webhook events
// @desc Handle Yoco webhook events (signature check and processing: src/payments)
// @route POST /api/webhooks/yoco
// @access Public
const handleYocoWebhook = async (req, res) => {
try {
const headers = req.headers;
const requestBody = req.rawBody;
// Log every JSON request body for webhook controller
try {
const contentType = headers['content-type'] || headers['Content-Type'] || '';
let parsedBody = null;
if (typeof requestBody === 'string') {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
// Not valid JSON; keep as raw string
}
} else if (requestBody && typeof requestBody === 'object') {
parsedBody = requestBody;
}
} catch (logErr) {
// Fail-safe: never block webhook processing due to logging issues
console.error('Failed to log webhook JSON request:', logErr);
}
// Verify webhook signature
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const rawSigHeader = headers['webhook-signature'];
if (!id || !timestamp || !rawSigHeader) {
const responseObj = {
success: false,
message: 'Missing webhook headers',
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
};
return res.status(400).json(responseObj);
}
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
}
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Get the webhook secret from environment variables
const secret = process.env.YOCO_WEBHOOK_SECRET;
if (!secret) {
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
const responseObj = {
success: false,
message: 'Server configuration error',
details: { error: 'Missing webhook secret configuration' }
};
return res.status(500).json(responseObj);
}
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Accept if any of the space-separated signatures match (supports key rotation)
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
const signatureValid = signatures.some(sig => {
try {
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
} catch {
return false;
}
});
if (!signatureValid) {
console.error('Invalid webhook signature');
const responseObj = {
success: false,
message: 'Invalid signature',
details: { error: 'Webhook signature verification failed' }
};
return res.status(403).json(responseObj);
}
// Parse the webhook payload
const webhookData = JSON.parse(requestBody);
// Extract checkoutId from payload or metadata
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
if (hasFilter) {
const idMatches = receivedCheckoutId && (
allowedIds.includes(receivedCheckoutId) ||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
);
if (!idMatches) {
// Store event for audit/unreconciled tracking
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
// Acknowledge but ignore processing for unrelated checkout flows
const responseObj = {
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: {
filtered: true,
receivedCheckoutId,
allowedIdsCount: allowedIds.length,
allowedPrefixesCount: allowedPrefixes.length
}
};
return res.status(200).json(responseObj);
}
}
} catch (filterErr) {
// Never block webhook due to filter parsing errors; log only
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Automatic filtering by database registration record, with donation allowance
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({
where: { checkoutId: receivedCheckoutId },
select: { id: true }
});
if (!existingRegistration) {
// Allow donations (no registration) to proceed based on metadata
const meta = webhookData?.payload?.metadata || {};
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
if (!isDonation) {
try {
await saveYocoTransaction(webhookData);
} catch (saveErr) {
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
}
const responseObj = {
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: {
filtered: true,
reason: 'no_registration_for_checkoutId',
receivedCheckoutId
}
};
return res.status(200).json(responseObj);
}
}
}
} catch (dbFilterErr) {
// Do not fail webhook due to DB filter issues
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
let processedData = null;
// Process different event types
switch (webhookData.type) {
case 'payment.succeeded':
processedData = await handlePaymentSucceeded(webhookData);
break;
// Add more event types as needed
default:
console.log(`Unhandled webhook event type: ${webhookData.type}`);
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
const responseObj = {
success: true,
message: 'Webhook received but event type not handled',
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp
}
};
return res.status(200).json(responseObj);
}
// Return detailed success response
const responseObj = {
success: true,
message: `Successfully processed ${webhookData.type} webhook`,
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp,
processedData
}
};
return res.status(200).json(responseObj);
} catch (error) {
console.error('Webhook error:', error);
const responseObj = {
success: false,
message: error.message,
details: {
error: error.stack,
timestamp: new Date().toISOString()
}
};
return res.status(500).json(responseObj);
}
};
// Handle payment.succeeded event
const handlePaymentSucceeded = async (webhookData) => {
try {
const paymentData = webhookData.payload;
// Log the payment data
console.log('Payment succeeded:', paymentData);
// Extract payment details
const {
id: yocoPaymentId,
amount,
currency,
status,
metadata,
paymentMethodDetails: method,
checkoutId
} = paymentData;
// Check if payment already exists to avoid duplicates
const existingPayment = await prisma.payment.findFirst({
where: {
externalId: yocoPaymentId
}
});
if (existingPayment) {
console.log(`Payment ${yocoPaymentId} already processed`);
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
return {
status: 'already_processed',
paymentId: existingPayment.id,
externalId: yocoPaymentId
};
}
// Find the registration by checkoutId
let registration = null;
if (checkoutId) {
registration = await prisma.registration.findFirst({
where: {
checkoutId: checkoutId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// If no registration found by checkoutId, try metadata
if (!registration && metadata && metadata.registrationId) {
registration = await prisma.registration.findUnique({
where: {
id: metadata.registrationId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
let resolvedUserId = registration?.userId || metadata?.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit
method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: true
}
} : undefined,
event: (registration?.eventId || metadata?.eventId) ? true : undefined
}
});
// If this payment is for a registration, update the registration status
let updatedRegistration = null;
let generatedTickets = [];
if (registration) {
updatedRegistration = await updateRegistrationStatus(registration.id);
// If registration status is 'paid', tickets were generated
if (updatedRegistration.status === 'paid') {
// Find the generated tickets
generatedTickets = await prisma.ticket.findMany({
where: {
registrationOption: {
registrationId: registration.id
}
},
select: {
id: true,
qrCode: true,
isUsed: true,
registrationOptionId: true,
eventId: true
}
});
}
}
console.log(`Payment ${yocoPaymentId} processed successfully`);
// Mark the Yoco transaction as reconciled and link the payment
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); }
// Send emails: payment confirmation first, then tickets (guarantees order)
const _whPaymentId = payment.id;
const _whUserId = registration?.userId;
const _whRegId = registration?.id;
const _whTicketsGenerated = generatedTickets.length > 0;
(async () => {
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(_whPaymentId);
} catch (e) {
console.error('Failed to send webhook payment emails:', e);
}
if (_whTicketsGenerated && _whUserId && _whRegId) {
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
}
})();
// Return processed data
return {
status: 'success',
payment: {
id: payment.id,
externalId: yocoPaymentId,
amount: payment.amount,
method: payment.method,
status: payment.status,
createdAt: payment.createdAt
},
registration: registration ? {
id: registration.id,
status: updatedRegistration ? updatedRegistration.status : registration.status,
eventId: registration.eventId,
eventTitle: registration.event?.title,
userId: registration.userId,
userName: registration.user?.name
} : null,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
};
} catch (error) {
console.error('Error processing payment.succeeded event:', error);
throw error;
}
};
// Update registration status based on payments
const updateRegistrationStatus = async (registrationId) => {
try {
// Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers
// are accounted for so we never mark a registration paid based on stale prices.
try { await refreshPricingForRegistration(registrationId); } catch (e) {
console.warn('[webhook] Price refresh failed:', e?.message);
}
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: {
include: {
earlyBirdTiers: true
}
},
tranches: true
}
},
payments: true
}
});
if (!registration) {
throw new Error(`Registration ${registrationId} not found`);
}
// Calculate total amount paid
const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0);
// Calculate total amount due (uses priceSnapshot — refreshed above)
const totalDue = computeRegistrationTotalDue(registration, new Date());
// Update registration status based on payment
let newStatus;
if (totalPaid >= totalDue) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial_paid';
} else {
newStatus = 'pending';
}
const updatedRegistration = await prisma.registration.update({
where: { id: registrationId },
data: {
status: newStatus,
updatedAt: new Date()
}
});
// Generate tickets if status is "paid" (email handled by caller after payment email)
let generatedTickets = [];
if (newStatus === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registrationId);
} catch (error) {
console.error('Error generating tickets:', error);
// Don't throw the error, just log it - we still want to update the registration status
}
}
// Return the updated registration
return {
...updatedRegistration,
totalPaid,
totalDue,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
};
} catch (error) {
console.error('Error updating registration status:', error);
throw error;
}
};
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
@@ -520,62 +18,3 @@ module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
// Helper: save or update Yoco transactions for any webhook event
async function saveYocoTransaction(webhookData, updates = {}) {
try {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
if (!externalId) {
throw new Error('Missing external id in webhook payload');
}
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
const currency = payload?.currency || null;
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
const methodType = payload?.paymentMethodDetails?.type || null;
// Try find existing
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing) {
// Update with any provided updates
if (updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({
where: { id: existing.id },
data: { ...updates, updatedAt: new Date() }
});
}
return existing;
}
// Create new
const record = await prisma.yocoTransaction.create({
data: {
externalId,
amount,
currency,
createdDate,
checkoutId,
methodType,
reconciled: false,
raw: webhookData,
...updates
}
});
return record;
} catch (e) {
// Unique constraint race: fetch existing
if (e?.code === 'P2002') {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing && updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
}
return existing;
}
throw e;
}
}
@@ -1,4 +1,5 @@
const prisma = require('../config/db');
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
@@ -82,9 +83,10 @@ const previewWhatsAppBroadcast = async (req, res) => {
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20) });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -123,9 +125,12 @@ const sendWhatsAppBroadcast = async (req, res) => {
if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
}
const recipients = Array.from(map.values());
// News unless marked as an event update; news skips people who haven't opted in to
// WhatsApp news, replied STOP or unsubscribed.
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
if (recipients.length === 0) {
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from WhatsApp news.' : 'No valid recipients with phone numbers' });
}
let event = null;
@@ -139,7 +144,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
const finalMessage = replacePlaceholders(message, ctx);
await sendText(rcpt.phone, finalMessage);
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
}));
const sent = results.filter(r => r.status === 'fulfilled').length;
@@ -149,7 +154,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
}
});
return res.json({ matched: recipients.length, sent });
return res.json({ kind, matched: recipients.length, skipped, sent });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -160,13 +165,13 @@ const sendWhatsAppBroadcast = async (req, res) => {
// @access Private/Supervisor or Admin
const scheduleWhatsAppBroadcast = async (req, res) => {
try {
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
const { scheduledAt, message, userIds, phones, eventId, kind } = req.body || {};
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
if (!message) return res.status(400).json({ message: 'message is required' });
const payload = { message, userIds, phones, eventId };
const payload = { message, userIds, phones, eventId, kind };
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
@@ -25,6 +25,7 @@ const getConfigHandler = async (req, res) => {
hasToken: !!token,
hasInstance: !!instanceId,
configured: !!(token && instanceId),
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
});
} catch (e) {
res.status(500).json({ message: e?.message || 'Failed to load config' });
@@ -162,6 +163,7 @@ const handleWebhook = async (req, res) => {
try {
const { event, session } = req.body || {};
if (event === 'message') return await handleIncomingMessage(req);
if (event !== 'session.status') return;
const status = session?.status;
@@ -210,6 +212,42 @@ const handleWebhook = async (req, res) => {
}
};
/**
* An incoming WhatsApp message (WAWP `message` event). The only thing we act on is an
* opt-out reply — "STOP" — which stops News & announcements on WhatsApp for that number
* (event updates still go out). Only trusted when the URL carries our webhook secret,
* since WAWP doesn't sign webhooks.
*/
async function handleIncomingMessage(req) {
const crypto = require('crypto');
const { getWebhookSecret, phoneFromChatId, sendText } = require('../utils/whatsapp');
const { isStopKeyword, whatsAppStop } = require('../utils/privacy');
const given = String(req.query?.secret || '');
const secret = await getWebhookSecret();
if (given.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret))) {
console.warn('[whatsapp webhook] Ignored a message event without a valid secret');
return;
}
// Proof for the POPIA readiness page that WAWP's webhook (and so STOP) is set up.
const prisma = require('../config/db');
const now = new Date().toISOString();
prisma.appSetting.upsert({ where: { key: 'wawp_webhook_last_seen' }, update: { value: now }, create: { key: 'wawp_webhook_last_seen', value: now } }).catch(() => {});
const payload = req.body?.payload || {};
if (payload.fromMe || !isStopKeyword(payload.body)) return;
const phone = phoneFromChatId(payload.from);
if (!phone) {
// Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number).
console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from);
return;
}
const result = await whatsAppStop(phone);
if (!result) return;
await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {});
}
module.exports = {
getConfigHandler,
saveConfigHandler,
+149
View File
@@ -0,0 +1,149 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
} = require('../utils/yoco');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
async function saveSettings(values) {
const ops = Object.entries(values).map(([key, value]) => {
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
}
const audit = (req, action, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
});
// @desc Yoco configuration with secrets masked
// @route GET /api/yoco/config
// @access Admin
const getConfig = async (req, res) => {
try {
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
]);
res.json({
secretKeyMasked: maskSecret(secretKey),
secretKeySource: source.secretKey,
mode: yocoMode(secretKey),
webhookSecretMasked: maskSecret(webhookSecret),
webhookSecretSource: source.webhookSecret,
webhookId: webhookId || '',
webhookUrl: defaultWebhookUrl(req),
configured: !!(secretKey && webhookSecret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save the secret key and/or webhook secret ('' clears)
// @route PUT /api/yoco/config
// @access Admin
const updateConfig = async (req, res) => {
try {
const { secretKey, webhookSecret } = req.body || {};
const updates = {};
if (typeof secretKey === 'string') {
const key = secretKey.trim();
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
}
// Check Yoco accepts the key before replacing a working one.
if (key) await listWebhooks(key);
updates[KEY_SECRET] = key;
}
if (typeof webhookSecret === 'string') {
const secret = webhookSecret.trim();
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
res.status(400); throw new Error('The webhook secret should start with whsec_.');
}
updates[KEY_WEBHOOK] = secret;
// A typed-in secret isn't tied to a webhook we registered.
updates[KEY_WEBHOOK_ID] = '';
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await saveSettings(updates);
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
res.json({ message: 'Yoco settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Webhooks registered on the Yoco account
// @route GET /api/yoco/webhooks
// @access Admin
const getWebhooks = async (req, res) => {
try {
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
const here = defaultWebhookUrl(req);
res.json({
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
defaultUrl: here,
});
} catch (e) {
fail(res, e);
}
};
// @desc Register a webhook at Yoco and store the secret it returns
// @route POST /api/yoco/webhooks
// @access Admin
const createWebhook = async (req, res) => {
try {
const name = String(req.body?.name || '').trim() || 'hope-events';
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
const problem = webhookUrlProblem(url);
if (problem) { res.status(400); throw new Error(problem); }
const hook = await registerWebhook(name, url);
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
res.status(201).json({
message: 'Webhook registered and its secret saved',
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
webhookSecretMasked: maskSecret(hook.secret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
// @route DELETE /api/yoco/webhooks/:id
// @access Admin
const removeWebhook = async (req, res) => {
try {
const { id } = req.params;
await deleteWebhook(id);
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
@@ -1,282 +0,0 @@
const prisma = require('../config/db');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
function getYocoModel() {
// Gracefully handle environments where the Prisma client hasn't been regenerated
// and YocoTransaction model is not available yet.
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
}
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
// @route GET /api/yoco-transactions
// @access Private/Admin or Supervisor
const getAllYocoTransactions = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const { reconciled, ignored } = req.query;
const where = {};
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where,
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
// @route GET /api/yoco-transactions/unreconciled
// @access Private/Admin or Supervisor
const getUnreconciledYocoTransactions = async (req, res) => {
try {
// Basic role check if auth middleware sets req.user
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where: { reconciled: false, ignored: false },
orderBy: { createdDate: 'desc' }
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch unreconciled Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
// @route POST /api/yoco-transactions/:id/reconcile
// @access Private/Admin or Supervisor
const reconcileYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.paymentId) {
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
}
// Load registration if provided
let registration = null;
if (registrationId) {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
event: true,
user: true,
registrationOptions: { include: { eventOption: true } },
payments: true
}
});
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
}
// Determine payment fields
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
if (!amountFloat || amountFloat <= 0) {
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
}
const externalId = ytx.externalId || undefined;
// Resolve a valid userId for the Payment to satisfy FK constraints
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
if (exists) resolvedUserId = metaUserId;
} catch {}
}
if (!resolvedUserId && req.user?.id) {
// Fallback to the acting supervisor/admin to avoid FK violations
resolvedUserId = req.user.id;
}
if (!resolvedUserId) {
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
}
// Build payment data
const paymentData = {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
externalId: externalId,
status: 'completed'
};
// Ensure donation has an eventId
if (!registration && !paymentData.eventId) {
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
}
// Create payment
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Optionally update registration status when applicable and generate tickets if paid.
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
let generatedTickets = [];
if (registration) {
try {
const updatedReg = await updateRegistrationStatus(registration.id);
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
} catch (genErr) {
console.error('Error generating tickets after Yoco reconciliation:', genErr);
}
}
} catch (e) {
// log but do not fail reconciliation
console.warn('Failed to update registration after reconciliation:', e?.message || e);
}
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
}
})();
// Update yoco transaction as reconciled
const updatedTx = await Yoco.update({
where: { id: ytx.id },
data: { reconciled: true, paymentId: payment.id }
});
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
} catch (error) {
console.error('Failed to reconcile Yoco transaction:', error);
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
if (error?.code === 'P2002') {
try {
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
if (existing) {
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
if (updatedTx) {
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
}
}
} catch {}
}
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
// Minimal registration status update mirroring webhook logic
async function updateRegistrationStatus(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true
}
});
if (!registration) return null;
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
let newStatus = 'pending';
if (totalPaid >= totalDue) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
}
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
// @route POST /api/yoco-transactions/:id/ignore
// @access Private/Admin or Supervisor
const ignoreYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.ignored) {
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
}
const updated = await Yoco.update({
where: { id },
data: { ignored: true, reconciled: true }
});
return res.status(200).json({ success: true, data: updated });
} catch (error) {
console.error('Failed to ignore Yoco transaction:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
module.exports = {
getAllYocoTransactions,
getUnreconciledYocoTransactions,
reconcileYocoTransaction,
ignoreYocoTransaction
};
+368 -119
View File
@@ -24,13 +24,10 @@ const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const prisma = require('./config/db');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
@@ -103,6 +100,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -130,7 +131,7 @@ const webhookRoutes = require('./routes/webhookRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
const reportRoutes = require('./routes/reportRoutes');
const formRoutes = require('./routes/formRoutes');
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
const providerTransactionRoutes = require('./routes/providerTransactionRoutes');
const automationRoutes = require('./routes/automationRoutes');
const broadcastRoutes = require('./routes/broadcastRoutes');
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
@@ -145,6 +146,9 @@ const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
const payfastRoutes = require('./routes/payfastRoutes');
const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -161,7 +165,8 @@ app.use('/api/tickets', ticketRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/forms', formRoutes);
app.use('/api/yoco-transactions', yocoTransactionRoutes);
app.use('/api/provider-transactions', providerTransactionRoutes);
app.use('/api/yoco-transactions', providerTransactionRoutes); // older name
app.use('/api/automations', automationRoutes);
app.use('/api/broadcasts', broadcastRoutes);
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
@@ -169,13 +174,26 @@ app.use('/api/scheduled-emails', scheduledEmailRoutes);
app.use('/api/sections', sectionRoutes);
app.use('/api/banner', bannerRoutes);
app.use('/api/whatsapp', whatsappRoutes);
app.use('/api/privacy', privacyRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/bundles', require('./routes/bundleRoutes'));
app.use('/api/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
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);
app.use('/api/payfast', payfastRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -380,6 +398,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -391,8 +410,24 @@ app.get('/docs', async (req, res) => {
request:{ body:{ email:'jane@example.com', password:'secret123' }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
]},
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
]},
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
notes:'At most one send per 60 seconds.',
request:{ body:{ challengeId:'c0ffee00-...' }},
responses:[
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
]},
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
request:{ body:{ email:'jane@example.com' }},
responses:[
@@ -424,6 +459,29 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
request:{ body:{ method:'email', password:'secret123' }},
responses:[
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
]},
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
request:{ body:{ password:'secret123' }},
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
responses:[
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
]},
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
request:{ body:{ password:'secret123' }},
responses:[
{ status:200, desc:'Correct', body:{ ok:true }},
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
]},
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
responses:[
@@ -455,7 +513,7 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user',
pathParams:{ ':id':'User UUID' },
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]},
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.',
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user (POPIA) — the account, the people they registered, form answers, consent details, sponsor names, login history and payer details in provider payloads; payments, registrations and tickets stay as "Deleted User". Refused (409) while they have a registration for an upcoming event. Completes an open deletion request. Irreversible; audited as user_anonymised.',
pathParams:{ ':id':'User UUID' },
responses:[
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
@@ -464,6 +522,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -486,7 +552,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 }}]},
@@ -541,6 +607,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -570,6 +642,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -588,11 +664,159 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
{ 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' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
]},
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
]},
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
]},
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
]},
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
{ 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.' }}]},
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
]},
{ title: 'Coupons', base: '/api/coupons', endpoints: [
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ method:'POST', path:'/api/payments/checkout', auth:'user+', desc:'Start an online checkout (also at /api/payments/yoco-checkout) with body.provider, or the first enabled provider. At least R15 (all providers). Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, provider:'yoco', successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
{ status:200, desc:'Checkout created — send the payer to redirectUrl', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', provider:'yoco', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
]},
{ method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)',
@@ -628,6 +852,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -658,6 +886,67 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
]},
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
{ method:'PUT', path:'/api/privacy/me/news', auth:'user', desc:'Change the News & announcements opt-in per channel (source "prompt" for the one-time question after login)',
request:{ body:{ email:false, whatsapp:false } },
responses:[{ status:200, desc:'Saved', body:{ news:{ email:false, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z' }}]},
{ method:'GET', path:'/api/privacy/me/export', auth:'user', desc:'Download my data — JSON, or ?format=pdf for a readable PDF. Blocked while impersonating',
responses:[{ status:200, desc:'File download' }]},
{ method:'POST', path:'/api/privacy/me/requests', auth:'user', desc:'Correction request ({ type:"correction", details }) or delete my account ({ type:"deletion", password }) — deletion closes the account at once',
request:{ body:{ type:'correction', details:'My son\'s date of birth on the Camp registration is wrong — it should be 3 May 2012.' } },
responses:[{ status:201, desc:'Created', body:{ request:{ id:'req-uuid-...', type:'correction', status:'open', dueAt:'2026-11-22T08:00:00.000Z' }, message:'Your request was sent.' }}, { status:409, desc:'Already open', body:{ message:'You already have an open correction request.' }}]},
{ method:'GET', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'What an unsubscribe link (signed token) is for',
responses:[{ status:200, desc:'Success', body:{ kind:'account', email:'jo***@example.com', hasPhone:true, news:{ email:true, whatsapp:true } }}]},
{ method:'POST', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'Unsubscribe from news ({ channels?: ["email","whatsapp"] }, default both)',
responses:[{ status:200, desc:'Done', body:{ message:'You have been unsubscribed.' }}]},
{ method:'POST', path:'/api/privacy/unsubscribe/:token/one-click', auth:'public', desc:'RFC 8058 one-click unsubscribe from the List-Unsubscribe email header (email news off)',
responses:[{ status:200, desc:'Done' }]},
{ method:'GET', path:'/api/privacy/requests', auth:'admin', desc:'Data requests inbox (?status=open|closed|all), with due dates and, for deletions, the registrations still blocking it',
responses:[{ status:200, desc:'Success', body:{ requests:[{ id:'req-uuid-...', type:'deletion', status:'open', name:'Jane Doe', email:'jane@example.com', dueAt:'2026-11-22T08:00:00.000Z', overdue:false, blockers:[] }], openCount:1 }}]},
{ method:'POST', path:'/api/privacy/requests/:id/complete', auth:'admin', desc:'Complete a request (emailed to the person). Deletion anonymises the account; 409 while it has a registration for an upcoming event',
request:{ body:{ response:'Corrected the date of birth on the Camp 2026 registration.' } },
responses:[{ status:200, desc:'Completed', body:{ request:{ status:'completed' }, anonymised:null }}]},
{ method:'POST', path:'/api/privacy/requests/:id/reject', auth:'admin', desc:'Reject a request, or mark it withdrawn (cancelled:true); a deletion request reopens the account',
request:{ body:{ response:'We could not find a registration with that name.', cancelled:false } },
responses:[{ status:200, desc:'Closed', body:{ request:{ status:'rejected' } }}]},
{ method:'GET', path:'/api/privacy/notice', auth:'public', desc:'The privacy notice (current, or ?version=N) with org details and the operator list filled in. Version 0 = built-in, until one is published',
responses:[{ status:200, desc:'Success', body:{ version:2, builtIn:false, current:true, effectiveDate:'23 October 2026', changeSummary:'We added WhatsApp news.', sections:[{ id:'introduction', title:'1. Introduction', body:'Hope Church values your privacy…' }], versions:[{ version:2, publishedAt:'2026-10-23T10:00:00.000Z', requiresAcceptance:true }] }}]},
{ method:'POST', path:'/api/privacy/me/notice/accept', auth:'user', desc:'Accept the current privacy notice version',
responses:[{ status:200, desc:'Accepted', body:{ version:2 }}]},
{ method:'GET', path:'/api/privacy/notice/draft', auth:'admin', desc:'Notice editor: draft (or current) sections, rendered preview, published versions with acceptance counts, built-in text',
responses:[{ status:200, desc:'Success', body:{ sections:[], rendered:[], isDraft:false, savedAt:null, versions:[{ version:1, acceptances:12 }] }}]},
{ method:'PUT', path:'/api/privacy/notice/draft', auth:'admin', desc:'Save the draft ({ sections: [{ id?, title, body }] })',
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }] } },
responses:[{ status:200, desc:'Saved', body:{ isDraft:true }}]},
{ method:'DELETE', path:'/api/privacy/notice/draft', auth:'admin', desc:'Discard the draft',
responses:[{ status:200, desc:'Discarded', body:{ message:'Draft discarded.' }}]},
{ method:'POST', path:'/api/privacy/notice/publish', auth:'admin', desc:'Publish the next version; requiresAcceptance asks everyone to accept it after their next login (needs changeSummary)',
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }], changeSummary:'We added WhatsApp news.', requiresAcceptance:true } },
responses:[{ status:201, desc:'Published', body:{ version:2, requiresAcceptance:true }}]},
{ method:'GET', path:'/api/privacy/operators', auth:'admin', desc:'The operator register (third parties that process personal information)',
responses:[{ status:200, desc:'Success', body:[{ id:'op-uuid-...', name:'PayFast', purpose:'Online payments', dataShared:'Name, email address, payment amount and reference', location:'South Africa', website:'https://payfast.io', active:true }] }]},
{ method:'POST', path:'/api/privacy/operators', auth:'admin', desc:'Add an operator (PUT/DELETE /api/privacy/operators/:id to change or remove)',
request:{ body:{ name:'Mailgun', purpose:'Sending emails', dataShared:'Name, email address, message content', location:'EU' } },
responses:[{ status:201, desc:'Created' }]},
{ method:'GET', path:'/api/privacy/consent-log', auth:'admin', desc:'Consent records: ?type=news|notice|parental, ?q= name/email, ?page=',
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', name:'Jane Doe', email:'jane@example.com', channel:'email', granted:false, source:'unsubscribe_link' }] }}]},
{ method:'GET', path:'/api/privacy/readiness', auth:'admin', desc:'POPIA readiness checklist with a score; each open item has a link to fix it',
responses:[{ status:200, desc:'Success', body:{ score:77, passed:10, total:13, checks:[{ key:'notice', label:'Privacy notice published', ok:true, detail:'2 versions published', href:'/dashboard/admin/privacy?tab=notice', applies:true }] }}]},
{ method:'GET', path:'/api/privacy/retention', auth:'admin', desc:'Retention policies, what the next run would affect, and the last run',
responses:[{ status:200, desc:'Success', body:{ policies:{ inactive_accounts:{ enabled:true, months:60 } }, preview:{ inactive_accounts:{ toWarn:3, toAnonymise:0 } }, reviewedAt:null }}]},
{ method:'PUT', path:'/api/privacy/retention', auth:'admin', desc:'Save the retention policies (all five)',
request:{ body:{ policies:{ inactive_accounts:{ enabled:true, months:60 }, form_answers:{ enabled:true, months:12 }, login_history:{ enabled:true, months:24 }, payment_details:{ enabled:true, months:24 }, audit_logs:{ enabled:true, months:60 } } } },
responses:[{ status:200, desc:'Saved' }]},
{ method:'POST', path:'/api/privacy/retention/run', auth:'admin', desc:'Run the retention job now (warn/anonymise inactive accounts, delete expired data)',
responses:[{ status:200, desc:'Done', body:{ result:{ inactive_accounts:{ warned:3, anonymised:0, waiting:0 }, login_history:{ deleted:120 } } }}]},
{ method:'GET', path:'/api/privacy/breaches', auth:'admin', desc:'The breach register (security compromises); POST to record one, PUT /:id to update or mark the Regulator/people told',
responses:[{ status:200, desc:'Success', body:[{ id:'br-uuid-...', title:'Phone with the attendee list lost', status:'open', discoveredAt:'2026-10-23T08:00:00.000Z', regulatorNotifiedAt:null }] }]},
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?sensitive=true, ?from=, ?to=, ?page=',
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', actor:'Sam Supervisor', action:'report_pdf', what:'Camp 2026 attendees', records:48 }] }}]},
]},
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
{ method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending',
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <john@example.com>', eventId:'ev-uuid-...' }},
@@ -707,6 +996,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -731,6 +1022,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -752,6 +1045,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -792,22 +1087,27 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]},
]},
{ title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [
{ method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]},
{ method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment',
pathParams:{ ':id':'YocoTransaction UUID' },
request:{ body:{ paymentId:'pay-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'YocoTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]},
{ title: 'Provider Transactions', base: '/api/provider-transactions', endpoints: [
{ method:'GET', path:'/api/provider-transactions', auth:'supervisor+', desc:'Transactions payment providers sent by webhook (also at /api/yoco-transactions)',
queryParams:{ provider:'yoco', reconciled:'true|false', ignored:'true|false' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }] }}]},
{ method:'GET', path:'/api/provider-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
queryParams:{ provider:'yoco' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }] }}]},
{ method:'POST', path:'/api/provider-transactions/:id/reconcile', auth:'supervisor+', desc:'Record the transaction as a payment on a registration, or as a donation to an event',
pathParams:{ ':id':'ProviderTransaction UUID' },
request:{ body:{ registrationId:'reg-uuid-...', eventId:'(donation) event-uuid-...', userId:'(donation, optional) user-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ success:true, data:{ transaction:{ id:'ptx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }, payment:{ id:'pay-uuid-...', provider:'yoco' } } }}]},
{ method:'POST', path:'/api/provider-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'ProviderTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ success:true, data:{ id:'ptx-uuid-...', ignored:true } }}]},
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -828,7 +1128,19 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
@@ -837,6 +1149,26 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'PayFast', base: '/api/payfast', endpoints: [
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
@@ -1197,100 +1529,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);
}
// 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 {
@@ -1352,6 +1593,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);
+279
View File
@@ -0,0 +1,279 @@
/**
* 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: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED',
run: () => require('../payments/status').runPaymentStatusJob(),
},
{
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: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
run: () => require('../utils/privacy').remindDueRequests(new Date()),
},
{
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
run: () => require('../utils/retention').runRetention(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 };
+40 -2
View File
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
// The setup wizard's token may finish setup without 2FA for this long.
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
// Protect routes - verify token
const protect = async (req, res, next) => {
@@ -29,7 +33,8 @@ const protect = async (req, res, next) => {
createdAt: true,
updatedAt: true,
phoneNumber: true,
tokenVersion: true
tokenVersion: true,
twoFactorEnabled: true
}
});
@@ -43,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -51,6 +67,18 @@ const protect = async (req, res, next) => {
return next(new Error('Session has been revoked. Please log in again.'));
}
// Admins and supervisors must use 2FA. A session that predates the
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
});
}
next();
} catch (error) {
console.error(error);
@@ -112,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+96
View File
@@ -0,0 +1,96 @@
/**
* Online checkouts for registrations (the balance, part of it, or the balance plus a
* donation). Used by the payment pages, payment links in emails and invoices, and the
* self-service kiosk. The provider is the one asked for, the only available one, or the
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
* webhook can find it.
*/
const prisma = require('../config/db');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const payments = require('./index');
const CHECKOUT_INCLUDE = {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true,
};
/**
* The checkout's line items for a registration: one per line (with its tranches' prices),
* then bundle savings and the coupon discount as deductions. Callers add payment lines.
* @returns {{ name: string, quantity: number, unitCents: number }[]}
*/
function registrationLineItems(registration) {
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
name: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
unitCents: Math.round(row.unitPrice * 100),
}));
for (const line of discountLines(registration)) items.push({ name: line.label, quantity: 1, unitCents: -Math.round(line.amount * 100) });
return items;
}
/**
* Starts a checkout for a registration. Doesn't check who's asking — callers do.
* @param {object} opts
* @param {number} [opts.amount] - pay only this much of the balance (at least the online minimum)
* @param {number} [opts.donationAmount] - added on top as its own line; the webhook records it
* as a separate donation (it travels in the checkout metadata)
* @param {string} [opts.provider] - payment provider id (default: first available)
* @returns {Promise<{ checkoutId, redirectUrl, provider, amount, registration }>}
*/
async function createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0, amount = null, provider = null } = {}) {
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: CHECKOUT_INCLUDE });
if (!registration) throw payments.paymentError('Registration not found', 404);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remaining = totalDue - totalPaid;
if (remaining <= 0) throw payments.paymentError('This registration is already paid in full');
let charge = remaining;
if (amount !== null && amount !== undefined) {
const requested = parseFloat(amount);
if (!(requested > 0)) throw payments.paymentError('Amount must be greater than 0');
charge = Math.min(requested, remaining);
}
const donationCents = Math.round((Number(donationAmount) || 0) * 100);
const chargeCents = Math.round(charge * 100);
const lineItems = registrationLineItems(registration);
if (totalPaid > 0) lineItems.push({ name: 'Previous Payments', quantity: 1, unitCents: -Math.round(totalPaid * 100) });
// Paying part of the balance: the rest is deferred to a later payment
if (charge < remaining) lineItems.push({ name: 'Partial Payment', quantity: 1, unitCents: -Math.round((remaining - charge) * 100) });
if (donationCents > 0) lineItems.push({ name: 'Donation', quantity: 1, unitCents: donationCents });
const amountCents = chargeCents + donationCents;
const checkout = await payments.createCheckout(provider, {
amountCents,
metadata: {
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
...(donationCents > 0 ? { donationAmount: (donationCents / 100).toFixed(2) } : {}),
},
lineItems,
successUrl, cancelUrl, failureUrl,
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
description: registration.event.title,
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
});
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: checkout.checkoutId, updatedAt: new Date() } });
return {
checkoutId: checkout.checkoutId,
redirectUrl: checkout.redirectUrl,
provider: checkout.provider,
amount: amount !== null && amount !== undefined ? charge : amountCents / 100,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title },
};
}
module.exports = { createRegistrationCheckout, registrationLineItems };
+178
View File
@@ -0,0 +1,178 @@
/**
* Online payment providers.
*
* Every provider implements the same interface (see providers/yoco.js, providers/payfast.js):
* id, label
* capabilities { refund, getTransaction, listTransactions } — what its API supports
* isConfigured() → Promise<boolean> credentials are set
* createCheckout(input) → Promise<{ externalId?, redirectUrl? } | { form: { action, fields } }>
* input: { checkoutId, amountCents, currency, metadata, lineItems,
* successUrl, cancelUrl, failureUrl, payer?, description,
* idempotencyKey }; lineItems: [{ name, quantity, unitCents }]
* (negative unitCents = a deduction). A provider that takes the
* payer with a signed form (PayFast) returns { form }; the payer's
* browser posts it from /pay/<checkoutId>.
* verifyWebhook(req) → Promise<{ ok: true } | { ok: false, status, message, details? }>
* parseWebhook(body) → { type: 'payment.succeeded' | <other>, transaction }
* body: the request body as the provider sent it (JSON or form)
* transaction: { externalId, amountCents, currency, createdDate,
* checkoutId, methodType, metadata, status }
* readWebhookBody(raw) → (optional) turns req.rawBody into the body; default JSON.parse
* getTransaction / refund / listTransactions — only when the capability is true
*
* Every checkout is recorded as a ProviderCheckout (amount, what it's for, return URLs), so
* the payment that comes back can be matched and checked, and so the payer can be sent to
* /pay/<id>: there they choose a provider when several are enabled, or their browser posts a
* provider's form. Admins switch providers on and off and set their order in Site settings →
* Payments (AppSetting `payment_providers`). Provider fees are never deducted or added:
* payments are recorded at the full amount and fees are handled at cashup.
*/
const prisma = require('../config/db');
const { getSetting } = require('../utils/settingsCache');
const PROVIDERS = [require('./providers/yoco'), require('./providers/payfast')];
const BY_ID = new Map(PROVIDERS.map(p => [p.id, p]));
// One minimum for every provider: online payments must be at least R15.
const MIN_ONLINE_AMOUNT = 15;
const SETTINGS_KEY = 'payment_providers';
function paymentError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** A provider by id, or null. */
function getProvider(id) {
return BY_ID.get(String(id || '')) || null;
}
/**
* Enabled flags and order from the stored setting. Pure. Unknown ids are dropped; providers
* not in the setting yet come last — Yoco enabled (it was the only provider before), others off.
* @param {string} raw - JSON: { order: string[], enabled: { [id]: boolean } }
* @param {string[]} [ids] - known provider ids, in default order
* @returns {{ id: string, enabled: boolean }[]}
*/
function parseProviderSettings(raw, ids = PROVIDERS.map(p => p.id)) {
let stored = {};
try { stored = raw ? JSON.parse(raw) : {}; } catch { stored = {}; }
const order = Array.isArray(stored.order) ? stored.order.filter(id => ids.includes(id)) : [];
const all = [...new Set([...order, ...ids])];
const enabled = stored.enabled && typeof stored.enabled === 'object' ? stored.enabled : {};
return all.map(id => ({ id, enabled: typeof enabled[id] === 'boolean' ? enabled[id] : id === 'yoco' }));
}
/** Every provider with its settings and whether it's configured, in display order. */
async function providerSettings() {
const list = parseProviderSettings(await getSetting(SETTINGS_KEY));
return Promise.all(list.map(async ({ id, enabled }) => {
const p = getProvider(id);
return { id, label: p.label, enabled, configured: await p.isConfigured(), capabilities: p.capabilities };
}));
}
/** Providers people can pay with now (enabled and configured), in display order. */
async function availableProviders() {
return (await providerSettings()).filter(p => p.enabled && p.configured);
}
/** The setting value to store for an ordered list of { id, enabled }. */
function serializeProviderSettings(list) {
const ids = list.map(p => p.id).filter(id => getProvider(id));
return JSON.stringify({ order: ids, enabled: Object.fromEntries(list.filter(p => getProvider(p.id)).map(p => [p.id, p.enabled === true])) });
}
/** The payer's page for a checkout: /pay/<id> on the frontend. */
function payPageUrl(checkoutId) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3000').split(',')[0].trim().replace(/\/$/, '');
return `${base}/pay/${encodeURIComponent(checkoutId)}`;
}
/** Throws unless `provider` may take payments now (enabled; and set up, or it explains). */
async function assertUsable(provider) {
const state = (await providerSettings()).find(p => p.id === provider.id);
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
}
/**
* Starts a recorded checkout with a provider. Idempotent for a provider that was already
* started with a redirect (Yoco); form providers rebuild their (deterministic) form.
* @returns {Promise<{ provider, redirectUrl, form? }>}
*/
async function startCheckout(checkout, provider) {
if (checkout.provider === provider.id && checkout.redirectUrl) return { provider: provider.id, redirectUrl: checkout.redirectUrl };
const request = checkout.request || {};
const started = await provider.createCheckout({
...request,
checkoutId: checkout.id,
amountCents: checkout.amountCents,
currency: checkout.currency,
metadata: checkout.metadata,
});
if (started.form) {
if (checkout.provider !== provider.id) await prisma.providerCheckout.update({ where: { id: checkout.id }, data: { provider: provider.id } });
return { provider: provider.id, redirectUrl: payPageUrl(checkout.id), form: started.form };
}
await prisma.providerCheckout.update({
where: { id: checkout.id },
data: { provider: provider.id, externalId: started.externalId || null, redirectUrl: started.redirectUrl },
});
return { provider: provider.id, redirectUrl: started.redirectUrl };
}
/**
* Starts a checkout with `providerId`, or the only available provider; with several
* available, the payer chooses on /pay/<id>.
* @param {object} input - { amountCents, metadata, lineItems, successUrl, cancelUrl, failureUrl,
* payer?: { name, email }, description?, idempotencyKey? }
* @returns {Promise<{ checkoutId, redirectUrl, provider: string|null }>}
*/
async function createCheckout(providerId, input) {
if (!(input.amountCents >= MIN_ONLINE_AMOUNT * 100)) {
throw paymentError(`Online payments must be at least R${MIN_ONLINE_AMOUNT}. Please pay at the door or contact the organiser.`);
}
let provider = null;
const available = await availableProviders();
if (providerId) {
provider = getProvider(providerId);
if (!provider) throw paymentError('Unknown payment provider.');
await assertUsable(provider);
} else if (available.length === 1) {
provider = getProvider(available[0].id);
} else if (available.length === 0) {
// Nothing enabled: say so. Enabled but not set up: the provider explains what's missing.
const enabled = (await providerSettings()).find(p => p.enabled);
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
provider = getProvider(enabled.id);
}
const { amountCents, metadata, currency, ...request } = input;
const checkout = await prisma.providerCheckout.create({
data: { amountCents, currency: currency || 'ZAR', metadata: metadata || {}, request },
});
if (!provider) return { checkoutId: checkout.id, redirectUrl: payPageUrl(checkout.id), provider: null };
try {
const started = await startCheckout(checkout, provider);
return { checkoutId: checkout.id, redirectUrl: started.redirectUrl, provider: started.provider };
} catch (e) {
await prisma.providerCheckout.delete({ where: { id: checkout.id } }).catch(() => {});
throw e;
}
}
/** The checkout a webhook is about: by our id, or by the provider's own checkout id. */
async function findCheckout(providerId, ref) {
if (!ref) return null;
const ours = await prisma.providerCheckout.findUnique({ where: { id: String(ref) } }).catch(() => null);
if (ours) return ours;
return prisma.providerCheckout.findFirst({ where: { provider: providerId, externalId: String(ref) } });
}
module.exports = {
PROVIDERS, MIN_ONLINE_AMOUNT, SETTINGS_KEY, paymentError,
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders,
payPageUrl, assertUsable, createCheckout, startCheckout, findCheckout,
};
+303
View File
@@ -0,0 +1,303 @@
/**
* PayFast as a payment provider (see ../index.js for the interface), using PayFast's
* "custom integration" (developers.payfast.co.za/docs → Custom Integration).
*
* Checkout: the payer's browser posts a signed form to https://<host>/eng/process (from our
* /pay/<checkoutId> page). Fields go in the order PayFast lists them; the signature is the MD5
* of the non-blank fields as name=value pairs (URL-encoded, upper-case escapes, spaces as '+')
* joined with '&', plus &passphrase=… when one is set.
*
* ITN (Instant Transaction Notification): PayFast posts the result, form-encoded, to
* /api/webhooks/payfast. Before trusting it we check, as PayFast requires: the signature, that
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast
* says it's VALID. The amount is checked against the checkout in ../webhook.js.
*
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
* transaction history (its API, signed differently — see apiSignature). Refunds aren't done
* through the API: they're made in the PayFast dashboard and recorded with the manual flow.
*/
const crypto = require('crypto');
const dns = require('dns').promises;
const axios = require('axios');
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
// getTransaction: payments can be looked up in PayFast's transaction history (findPayments).
const capabilities = { refund: false, getTransaction: true, listTransactions: false };
// Hosts PayFast sends ITNs from.
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
async function isConfigured() {
const c = await getPayfastConfig();
return !!(c.merchantId && c.merchantKey);
}
/**
* URL-encodes a value the way PayFast's signature expects (PHP urlencode): every character
* but letters, digits and -_. is %-escaped in upper case, and spaces become '+'. Checked
* against the sandbox: PayFast's own Node sample, which leaves ' ( ) ! * ~ as they are, is
* rejected for names like "Men's Camp (Youth)"; this is accepted.
*/
function pfEncode(value) {
return encodeURIComponent(String(value).trim())
.replace(/[!'()*~]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%20/g, '+');
}
/**
* name=value&… of the pairs, in order. Pure. A checkout signs only the non-blank fields; an
* ITN is checked over every field PayFast posted, blanks included (`allFields`).
*/
function paramString(pairs, { allFields = false } = {}) {
const keep = allFields ? pairs : pairs.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
return keep.map(([k, v]) => `${k}=${pfEncode(v ?? '')}`).join('&');
}
/** PayFast's MD5 signature over ordered pairs (and the passphrase, if any). Pure. */
function signature(pairs, passphrase = '', opts = {}) {
let s = paramString(pairs, opts);
if (passphrase) s += `&passphrase=${pfEncode(passphrase)}`;
return crypto.createHash('md5').update(s).digest('hex');
}
/** Text we send PayFast (item and payer names): one line, within PayFast's length limit. Pure. */
function cleanText(value, max) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
}
/** "Camp ticket x2, T-shirt" → the item description (charged lines only). Pure. */
function describeLines(lineItems) {
return (lineItems || []).filter(l => l.unitCents > 0).map(l => `${l.name}${l.quantity > 1 ? ` x${l.quantity}` : ''}`).join(', ');
}
async function createCheckout({ checkoutId, amountCents, successUrl, cancelUrl, payer, description, lineItems }) {
const c = await getPayfastConfig();
if (!c.merchantId || !c.merchantKey) {
const err = new Error('PayFast isn\'t set up. An admin needs to add the merchant ID and key in Site Settings → Payments → PayFast.');
err.statusCode = 503;
throw err;
}
const notifyUrl = payfastNotifyUrl();
const [first, ...rest] = String(payer?.name || '').trim().split(/\s+/);
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(payer?.email || '')) && !String(payer.email).endsWith('.local') && !String(payer.email).endsWith('.invalid') ? payer.email : '';
const items = describeLines(lineItems);
// In the order PayFast documents them — the signature depends on it.
const pairs = [
['merchant_id', c.merchantId],
['merchant_key', c.merchantKey],
['return_url', successUrl],
['cancel_url', cancelUrl],
['notify_url', notifyUrl],
['name_first', cleanText(first, 100)],
['name_last', cleanText(rest.join(' '), 100)],
['email_address', email],
['m_payment_id', checkoutId],
['amount', (amountCents / 100).toFixed(2)],
['item_name', cleanText(description || items || 'Payment', 100)],
['item_description', cleanText(items, 255)],
].filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
const fields = Object.fromEntries(pairs.map(([k, v]) => [k, String(v).trim()]));
fields.signature = signature(pairs, c.passphrase);
return { form: { action: `https://${payfastHost(c.mode)}/eng/process`, fields } };
}
/** The ITN's name/value pairs, decoded, in the order PayFast sent them. Pure. */
function parsePairs(rawBody) {
return String(rawBody || '').split('&').filter(Boolean).map(kv => {
const i = kv.indexOf('=');
const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
return i < 0 ? [dec(kv), ''] : [dec(kv.slice(0, i)), dec(kv.slice(i + 1))];
});
}
function readWebhookBody(rawBody) {
return Object.fromEntries(parsePairs(rawBody));
}
/** The caller's IP, without IPv4-mapped-IPv6 noise (needs TRUST_PROXY behind a proxy). */
function requestIp(req) {
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
}
async function payfastIps() {
const all = await Promise.all(VALID_HOSTS.map(h => dns.lookup(h, { all: true }).catch(() => [])));
return new Set(all.flat().map(a => a.address));
}
/** PayFast's four ITN checks, less the amount (checked against the checkout in ../webhook.js). */
async function verifyWebhook(req, { lookupIps = payfastIps, post = axios.post } = {}) {
const pairs = parsePairs(req.rawBody);
const data = Object.fromEntries(pairs);
if (!data.signature || !data.pf_payment_id) return { ok: false, status: 400, message: 'Not a PayFast notification' };
const c = await getPayfastConfig();
if (!c.merchantId) return { ok: false, status: 500, message: 'PayFast isn\'t set up' };
if (data.merchant_id && data.merchant_id !== c.merchantId) return { ok: false, status: 403, message: 'Notification for a different PayFast merchant' };
// 1. Signature: every posted field before the signature, in order, plus our passphrase.
const signed = [];
for (const pair of pairs) { if (pair[0] === 'signature') break; signed.push(pair); }
if (signature(signed, c.passphrase, { allFields: true }) !== data.signature) {
console.error('PayFast ITN: invalid signature');
return { ok: false, status: 403, message: 'Invalid signature' };
}
// 2. It came from PayFast.
if (process.env.PAYFAST_SKIP_IP_CHECK !== 'true') {
const ip = requestIp(req);
if (!(await lookupIps()).has(ip)) {
console.error(`PayFast ITN from ${ip}, which isn't a PayFast address (behind a proxy? set TRUST_PROXY)`);
return { ok: false, status: 403, message: 'Notification did not come from PayFast' };
}
}
// 3. PayFast confirms the details.
let reply = '';
try {
const res = await post(`https://${payfastHost(c.mode)}/eng/query/validate`, paramString(signed, { allFields: true }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 });
reply = String(res.data || '').trim();
} catch (e) {
console.error('PayFast ITN: could not confirm with PayFast:', e.message);
return { ok: false, status: 502, message: 'Could not confirm the notification with PayFast' };
}
if (reply !== 'VALID') {
console.error('PayFast ITN: PayFast did not confirm it:', reply.slice(0, 100));
return { ok: false, status: 403, message: 'PayFast did not confirm the notification' };
}
return { ok: true, webhookId: data.pf_payment_id, timestamp: null };
}
/** An ITN body → { type, transaction }. Pure. */
function parseWebhook(body) {
const status = String(body?.payment_status || '').toUpperCase();
const gross = parseFloat(body?.amount_gross);
return {
type: status === 'COMPLETE' ? 'payment.succeeded' : `payment.${status.toLowerCase() || 'unknown'}`,
transaction: {
externalId: body?.pf_payment_id || null,
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
currency: 'ZAR',
createdDate: null,
checkoutId: body?.m_payment_id || null,
// PayFast doesn't say how the payer paid; it settles like a card payment.
methodType: null,
metadata: {},
status: status === 'COMPLETE' ? 'succeeded' : status.toLowerCase(),
},
};
}
// ─── PayFast API (developers.payfast.co.za/api) ──────────────────────────────
const API_BASE = 'https://api.payfast.co.za';
/**
* The API signature: every header and query variable plus the passphrase, sorted
* alphabetically, URL-encoded, joined with '&' (not the payment form's field order). Pure.
*/
function apiSignature(vars) {
const s = Object.keys(vars).sort()
.filter(k => vars[k] !== undefined && vars[k] !== null && String(vars[k]) !== '')
.map(k => `${k}=${pfEncode(vars[k])}`).join('&');
return crypto.createHash('md5').update(s).digest('hex');
}
/** A signed GET to the PayFast API; the sandbox adds ?testing=true (left out of the signature). */
async function apiGet(path, query = {}, { get = axios.get } = {}) {
const c = await getPayfastConfig();
const headers = { 'merchant-id': c.merchantId, version: 'v1', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') };
const signature = apiSignature({ ...headers, ...query, ...(c.passphrase ? { passphrase: c.passphrase } : {}) });
const params = { ...query, ...(c.mode === 'live' ? {} : { testing: 'true' }) };
try {
const res = await get(`${API_BASE}${path}`, { params, headers: { ...headers, signature }, timeout: 20000, responseType: 'text', transformResponse: r => r });
return res.data;
} catch (e) {
const status = e.response?.status;
let detail = '';
try { detail = JSON.parse(e.response?.data || '{}')?.data?.response || ''; } catch { /* not JSON */ }
const err = new Error(status === 401
? 'PayFast refused the merchant ID or passphrase (check they match your PayFast dashboard, and the mode).'
: `Could not reach PayFast${detail ? `: ${detail}` : status ? ` (${status})` : `: ${e.message}`}`);
err.statusCode = status === 401 ? 400 : 502;
throw err;
}
}
/** Checks the credentials with PayFast (GET /ping, signed). */
async function testConnection(deps) {
await apiGet('/ping', {}, deps);
return true;
}
/** Rows of a CSV with a header line, as objects keyed by the header. Pure. */
function parseCsv(text) {
const rows = [];
let row = [], field = '', quoted = false;
const s = String(text || '');
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (quoted) {
if (ch === '"' && s[i + 1] === '"') { field += '"'; i++; }
else if (ch === '"') quoted = false;
else field += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') { row.push(field); field = ''; }
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && s[i + 1] === '\n') i++;
row.push(field); field = '';
if (row.some(v => v !== '')) rows.push(row);
row = [];
} else field += ch;
}
row.push(field);
if (row.some(v => v !== '')) rows.push(row);
const [header, ...data] = rows;
return header ? data.map(r => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))) : [];
}
const day = (d) => new Date(d).toISOString().slice(0, 10);
/**
* Payments PayFast received for our checkouts (from its transaction history, matched on
* "M Payment ID" = our checkout id). For notifications (ITNs) that never arrived.
* @param {object[]} checkouts - ProviderCheckout rows
* @returns {Promise<Map<string, object>>} checkout id → transaction (as parseWebhook gives)
*/
async function findPayments(checkouts, deps) {
const found = new Map();
if (!checkouts.length) return found;
const ids = new Set(checkouts.map(c => c.id));
const from = day(Math.min(...checkouts.map(c => new Date(c.createdAt).getTime())) - 24 * 60 * 60 * 1000);
const to = day(Date.now() + 24 * 60 * 60 * 1000);
const PAGE = 1000;
for (let offset = 0; offset < 20 * PAGE; offset += PAGE) {
const rows = parseCsv(await apiGet('/transactions/history', { from, to, limit: PAGE, offset }, deps));
for (const r of rows) {
const id = r['M Payment ID'];
if (!ids.has(id) || r.Type !== 'FUNDS_RECEIVED' || found.has(id)) continue;
const gross = parseFloat(String(r.Gross).replace(/,/g, ''));
found.set(id, {
externalId: r['PF Payment ID'] || null,
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
currency: r.Currency || 'ZAR',
// PayFast's history times are South African time
createdDate: r.Date ? new Date(`${r.Date.replace(' ', 'T')}+02:00`) : null,
checkoutId: id,
methodType: null,
metadata: {},
status: 'succeeded',
});
}
if (rows.length < PAGE || found.size === ids.size) break;
}
return found;
}
module.exports = {
id: 'payfast', label: 'PayFast', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
// for tests
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
};
+107
View File
@@ -0,0 +1,107 @@
/**
* Yoco as a payment provider (see ../index.js for the interface).
*
* Checkouts: POST /checkouts on the Yoco Checkout API; Yoco redirects the payer back and
* posts a `payment.succeeded` webhook to /api/webhooks/yoco. Webhooks are signed with the
* webhook secret (Standard Webhooks: HMAC-SHA256 over "id.timestamp.body").
* Keys and webhooks are managed in Site settings → Payments → Yoco (utils/yoco.js).
* Refunds and transaction lookups aren't done through Yoco's API here: refunds are made in
* the Yoco portal and recorded with the manual refund flow.
*/
const crypto = require('crypto');
const { getYocoSecretKey, getYocoWebhookSecret, yocoRequest } = require('../../utils/yoco');
// Yoco recommends rejecting webhooks more than 3 minutes old (replays).
const WEBHOOK_TOLERANCE_MS = 3 * 60 * 1000;
const capabilities = { refund: false, getTransaction: false, listTransactions: false };
async function isConfigured() {
return !!(await getYocoSecretKey());
}
/** Our line items → Yoco's (amounts in cents; a negative price is a deduction line). */
function toYocoLineItems(lineItems) {
return (lineItems || []).map(l => ({ displayName: l.name, quantity: l.quantity, pricingDetails: { price: l.unitCents } }));
}
async function createCheckout({ amountCents, currency, metadata, lineItems, successUrl, cancelUrl, failureUrl, idempotencyKey }) {
const data = await yocoRequest('post', '/checkouts', {
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
return { externalId: data.id, redirectUrl: data.redirectUrl };
}
/**
* The signature Yoco sends for a webhook. Pure.
* @param {string} secret - whsec_<base64>
*/
function webhookSignature(secret, id, timestamp, rawBody) {
const secretBytes = Buffer.from(String(secret).split('_')[1] || '', 'base64');
return crypto.createHmac('sha256', secretBytes).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
}
/** Checks the webhook headers, age and signature of an incoming request (req.rawBody). */
async function verifyWebhook(req, now = Date.now()) {
const headers = req.headers || {};
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const rawSigHeader = headers['webhook-signature'];
if (!id || !timestamp || !rawSigHeader) {
return {
ok: false, status: 400, message: 'Missing webhook headers',
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) },
};
}
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
if (isNaN(webhookTimeMs) || Math.abs(now - webhookTimeMs) > WEBHOOK_TOLERANCE_MS) {
return { ok: false, status: 400, message: 'Webhook timestamp outside acceptable window' };
}
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('Yoco webhook secret is not configured (Site Settings → Payments → Yoco, or YOCO_WEBHOOK_SECRET)');
return { ok: false, status: 500, message: 'Server configuration error', details: { error: 'Missing webhook secret configuration' } };
}
const expected = webhookSignature(secret, id, timestamp, req.rawBody);
// Accept if any of the space-separated signatures match (supports key rotation)
const signatures = String(rawSigHeader).split(' ').map(s => s.split(',')[1]).filter(Boolean);
const valid = signatures.some(sig => {
try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); } catch { return false; }
});
if (!valid) {
console.error('Invalid webhook signature');
return { ok: false, status: 403, message: 'Invalid signature', details: { error: 'Webhook signature verification failed' } };
}
return { ok: true, webhookId: id, timestamp };
}
/** A Yoco webhook body → { type, transaction }. Pure. */
function parseWebhook(body) {
const payload = body?.payload || {};
const metadata = payload.metadata || {};
const createdDate = payload.createdDate || body?.createdDate;
return {
type: body?.type,
transaction: {
externalId: payload.id || body?.id || null,
amountCents: typeof payload.amount === 'number' ? payload.amount : null,
currency: payload.currency || null,
createdDate: createdDate ? new Date(createdDate) : null,
checkoutId: payload.checkoutId || metadata.checkoutId || metadata.checkout_id || null,
methodType: payload.paymentMethodDetails?.type || null,
metadata,
status: payload.status || null,
},
};
}
module.exports = {
id: 'yoco', label: 'Yoco', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook,
// for tests
webhookSignature, toYocoLineItems,
};
+99
View File
@@ -0,0 +1,99 @@
/**
* Payment status checks — for when a provider's notification (webhook / ITN) never arrives.
*
* An open checkout is one we started with a provider that has no payment recorded yet. A
* provider that can look payments up (capabilities.getTransaction, findPayments) is asked
* whether it received one; anything it found is settled exactly as its webhook would have been
* (same amount check, same recording, same emails). Run every 15 minutes by the
* 'payment-status' job for recent checkouts, and on demand from the Payments page.
* Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their
* payments are found in the provider's portal and recorded with Reconcile.
*/
const prisma = require('../config/db');
const { getProvider } = require('./index');
const { settlePayment } = require('./webhook');
/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */
async function checkoutPaid(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
if (tx) return true;
const registrationId = checkout.metadata?.registrationId;
if (!registrationId) return false;
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
return reg?.status === 'paid';
}
/** A payment held for review on this checkout (paid a different amount), if any. */
async function heldForReview(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } });
}
/**
* Asks each checkout's provider whether it was paid and records what it finds.
* @returns {Promise<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
*/
async function checkCheckouts(checkouts) {
const out = new Map();
const byProvider = new Map();
for (const c of checkouts) {
if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; }
const provider = getProvider(c.provider);
if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; }
if (!byProvider.has(provider.id)) byProvider.set(provider.id, []);
byProvider.get(provider.id).push(c);
}
for (const [providerId, list] of byProvider) {
const provider = getProvider(providerId);
const found = await provider.findPayments(list);
for (const c of list) {
const tx = found.get(c.id);
if (!tx) {
const held = await heldForReview(c);
out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' });
continue;
}
const event = {
type: 'payment.succeeded',
transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } },
checkoutRefs: [c.id, c.externalId].filter(Boolean),
};
const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c);
out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' });
}
}
return out;
}
/** Recent checkouts started with a provider and not paid yet, newest first. */
async function openCheckouts({ since, until = new Date() } = {}) {
const rows = await prisma.providerCheckout.findMany({
where: { provider: { not: null }, createdAt: { gte: since, lte: until } },
orderBy: { createdAt: 'desc' },
take: 200,
});
const open = [];
for (const c of rows) if (!(await checkoutPaid(c))) open.push(c);
return open;
}
/**
* Background job: checks checkouts started between 3 days and 10 minutes ago (giving the
* webhook time to arrive first) with providers that can be asked.
*/
async function runPaymentStatusJob(now = new Date()) {
const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const until = new Date(now.getTime() - 10 * 60 * 1000);
const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments);
if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 };
const results = [...(await checkCheckouts(checkable)).values()];
return {
checked: checkable.length,
recorded: results.filter(r => r.status === 'recorded').length,
needsReview: results.filter(r => r.status === 'needs_review').length,
};
}
module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob };
+301
View File
@@ -0,0 +1,301 @@
/**
* Payment webhooks, for every provider: the provider checks the signature and reads the
* body (providers/*.js); everything after that is shared here.
*
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
* Payments page.
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
* different amount isn't recorded — it waits on the Payments page with a note.
* - payment.succeeded for a registration or donation records a Payment at the full amount
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
* updates the registration's status and generates tickets once it's paid, then emails.
* - A provider that has been switched off still has its webhooks processed, so checkouts
* started before it was turned off complete.
*/
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { getProvider, findCheckout } = require('./index');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
/** Express handler for POST /api/webhooks/<providerId> (needs req.rawBody). */
const handleProviderWebhook = (providerId) => async (req, res) => {
try {
const provider = getProvider(providerId);
if (!provider) return res.status(404).json({ success: false, message: 'Unknown payment provider' });
const verified = await provider.verifyWebhook(req);
if (!verified.ok) {
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
}
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
const event = provider.parseWebhook(body);
const tx = event.transaction;
const receivedCheckoutId = tx.checkoutId;
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
// A registration remembers our checkout id (older ones: the provider's)
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
if (allowedIds.length > 0 || allowedPrefixes.length > 0) {
const idMatches = receivedCheckoutId && (allowedIds.includes(receivedCheckoutId) || allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)));
if (!idMatches) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store filtered webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: { filtered: true, receivedCheckoutId, allowedIdsCount: allowedIds.length, allowedPrefixesCount: allowedPrefixes.length },
});
}
}
} catch (filterErr) {
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Not one of our registration checkouts and not a donation → keep it for reconciling
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
if (!isDonation) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: { filtered: true, reason: 'no_registration_for_checkoutId', receivedCheckoutId },
});
}
}
}
} catch (dbFilterErr) {
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
if (event.type !== 'payment.succeeded') {
console.log(`Unhandled ${provider.id} webhook event type: ${event.type}`);
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store unhandled webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook received but event type not handled',
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp },
});
}
const settled = await settlePayment(provider, event, body, checkout);
if (settled.needsReview) {
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
}
const processedData = settled.processedData;
return res.status(200).json({
success: true,
message: `Successfully processed ${event.type} webhook`,
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp, processedData },
});
} catch (error) {
console.error('Webhook error:', error);
return res.status(500).json({ success: false, message: error.message, details: { error: error.stack, timestamp: new Date().toISOString() } });
}
};
/**
* A successful payment for one of our checkouts (from a webhook, or found by a status check):
* held for review with a note when the amount differs from the checkout, otherwise recorded.
* @returns {Promise<{ needsReview: true, note } | { needsReview: false, processedData }>}
*/
async function settlePayment(provider, event, body, checkout) {
const tx = event.transaction;
if (checkout && tx.amountCents !== checkout.amountCents) {
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
return { needsReview: true, note };
}
return { needsReview: false, processedData: await recordSucceededPayment(provider.id, event, body) };
}
const REG_INCLUDE ={ event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
async function recordSucceededPayment(providerId, event, body) {
const { externalId, amountCents, status, metadata = {}, methodType, checkoutId } = event.transaction;
console.log(`Payment succeeded (${providerId}):`, externalId);
const existingPayment = await prisma.payment.findFirst({ where: { externalId } });
if (existingPayment) {
console.log(`Payment ${externalId} already processed`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existingPayment.id }); } catch (e) { console.warn('Failed to reconcile transaction for existing payment:', e?.message || e); }
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
}
let registration = null;
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
let resolvedUserId = registration?.userId || metadata.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch { /* payment will fail below if still null */ }
}
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
// recorded as its own donation payment so it never counts towards the registration.
const grossAmount = amountCents / 100;
const donationPart = registration?.id ? Math.min(Math.max(Number(metadata.donationAmount) || 0, 0), grossAmount) : 0;
const method = methodType || 'card';
const paymentStatus = status === 'succeeded' ? 'completed' : status;
const payment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: Math.round((grossAmount - donationPart) * 100) / 100,
method,
provider: providerId,
status: paymentStatus,
externalId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata.eventId || null,
isDonation: !registration?.id,
...(!registration?.id && metadata.type === 'sponsorship' ? {
isSponsorship: true,
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
} : {}),
},
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(), amount: donationPart, method, provider: providerId, status: paymentStatus,
userId: resolvedUserId, recordedById: resolvedUserId, registrationId: null, eventId: registration.eventId,
isDonation: true, originalPaymentId: payment.id,
},
});
}
let updatedRegistration = null;
let generatedTickets = [];
if (registration) {
updatedRegistration = await updateRegistrationStatus(registration.id);
if (updatedRegistration.status === 'paid') {
generatedTickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
select: { id: true, qrCode: true, isUsed: true, registrationOptionId: true, eventId: true },
});
}
}
console.log(`Payment ${externalId} processed successfully`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: payment.id }); } catch (e) { console.warn('Failed to reconcile transaction after payment create:', e?.message || e); }
// Emails: payment confirmation first, then tickets (guarantees order)
const regUserId = registration?.userId;
const regId = registration?.id;
const sendTickets = generatedTickets.length > 0;
(async () => {
const { sendPaymentEmails } = require('../utils/notifications');
try { await sendPaymentEmails(payment.id); } catch (e) { console.error('Failed to send webhook payment emails:', e); }
if (donationPayment) {
try { await sendPaymentEmails(donationPayment.id); } catch (e) { console.error('Failed to send webhook donation emails:', e); }
}
if (sendTickets && regUserId && regId) {
const { emailTickets } = require('../controllers/ticketController');
const mockReq = { user: { id: regUserId }, body: { registrationId: regId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
}
})();
return {
status: 'success',
payment: { id: payment.id, externalId, amount: payment.amount, method: payment.method, status: payment.status, createdAt: payment.createdAt },
registration: registration ? {
id: registration.id,
status: updatedRegistration ? updatedRegistration.status : registration.status,
eventId: registration.eventId,
eventTitle: registration.event?.title,
userId: registration.userId,
userName: registration.user?.name,
} : null,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
};
}
/** Sets a registration's status from its payments; generates tickets once it's paid. */
async function updateRegistrationStatus(registrationId) {
// Refresh early-bird pricing first so a registration is never marked paid on stale prices.
try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('[webhook] Price refresh failed:', e?.message); }
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
if (!registration) throw new Error(`Registration ${registrationId} not found`);
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const newStatus = totalPaid >= totalDue ? 'paid' : totalPaid > 0 ? 'partial_paid' : 'pending';
const updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
// Tickets are generated here; the caller emails them after the payment email.
let generatedTickets = [];
if (newStatus === 'paid') {
try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); }
}
return { ...updatedRegistration, totalPaid, totalDue, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined };
}
/** Stores (or updates) the transaction behind a webhook event. Idempotent per provider + id. */
async function saveProviderTransaction(providerId, event, body, updates = {}) {
const tx = event.transaction;
const externalId = tx.externalId;
if (!externalId) throw new Error('Missing external id in webhook payload');
const where = { provider_externalId: { provider: providerId, externalId } };
const touch = (existing) => (Object.keys(updates).length
? prisma.providerTransaction.update({ where: { id: existing.id }, data: updates })
: existing);
try {
const existing = await prisma.providerTransaction.findUnique({ where });
if (existing) return touch(existing);
return await prisma.providerTransaction.create({
data: {
provider: providerId, externalId,
amount: tx.amountCents, currency: tx.currency, createdDate: tx.createdDate,
checkoutId: tx.checkoutId, methodType: tx.methodType,
reconciled: false, raw: body, ...updates,
},
});
} catch (e) {
// Unique constraint race: another request stored it first
if (e?.code === 'P2002') {
const existing = await prisma.providerTransaction.findUnique({ where });
return existing ? touch(existing) : null;
}
throw e;
}
}
module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
+10
View File
@@ -1,8 +1,18 @@
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);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const {
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
} = require('../controllers/allocationController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
router.get('/my-groups', protect, staff, getMyGroups);
router.get('/event/:eventId', protect, supervisor, getBoard);
router.post('/sets', protect, supervisor, createSet);
router.put('/sets/:id', protect, supervisor, updateSet);
router.delete('/sets/:id', protect, supervisor, deleteSet);
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
router.post('/sets/:id/groups', protect, supervisor, createGroup);
router.put('/sets/:id/assignments', protect, supervisor, assign);
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
router.put('/groups/:id', protect, supervisor, updateGroup);
router.delete('/groups/:id', protect, supervisor, deleteGroup);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { trackStage, getFunnel } = require('../controllers/analyticsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const trackLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(204).end(), // silently drop floods
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, getFunnel);
module.exports = router;
+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;
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// What a basket gets off before registering (guests included)
router.post('/quote', optionalAuth, quoteBundles);
router.get('/event/:eventId', protect, supervisor, listBundles);
router.post('/event/:eventId', protect, supervisor, createBundle);
router.put('/:id', protect, supervisor, updateBundle);
router.delete('/:id', protect, supervisor, deleteBundle);
module.exports = router;
+2 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
@@ -10,5 +10,6 @@ router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePerso
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
// The token in the link is the parent's only credential, so keep guessing slow.
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.get('/events/:eventId', protect, staff, listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
router.post('/requests/:id/resend', protect, resendRequest);
// Public consent page
router.get('/:token', consentLimiter, getConsent);
router.post('/:token', consentLimiter, answerConsent);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.get('/', protect, supervisor, listCoupons);
router.post('/', protect, supervisor, createCoupon);
router.get('/redemptions', protect, supervisor, listRedemptions);
router.get('/:id', protect, supervisor, getCoupon);
router.put('/:id', protect, supervisor, updateCoupon);
module.exports = router;
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
const { protect, supervisor } = require('../middleware/authMiddleware');
router.get('/public/:token', getPublicForm);
router.post('/public/:token', submitPublicForm);
router.get('/summary', protect, supervisor, getSummary);
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.post('/test', protect, admin, testConnection);
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More